diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a6d41..6e5db79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Add CLI for Persephone as two commands that can be made using `make bundle`; command options can be viewed using `--help`. - `validate`: Performs Statement Template validation for a single Statement - `match`: Performs Pattern matching for a Statement batch +- Add webserver that can be started in either `validate` or `match` mode, then perform validation/matching by providing Statements at the `POST /statements` endpoint. - Move Clojure and ClojureScript dependencies out of main deps into alias (extra) deps - Added printing for pattern match errors (not just failures) - Change Statement validation error message header and assert message from `Invalid Statement` to `Statement Validation Failure` diff --git a/Makefile b/Makefile index d5c0e35..5e7cb5c 100644 --- a/Makefile +++ b/Makefile @@ -29,11 +29,14 @@ ci: clean test-clj test-cljs target/bundle/cli.jar: clojure -T:build uber :jar cli +target/bundle/server.jar: + clojure -T:build uber :jar server + target/bundle/bin: mkdir -p target/bundle/bin cp bin/*.sh target/bundle/bin chmod +x target/bundle/bin/*.sh -target/bundle: target/bundle/cli.jar target/bundle/bin +target/bundle: target/bundle/cli.jar target/bundle/server.jar target/bundle/bin bundle: target/bundle diff --git a/README.md b/README.md index 860b64a..032e511 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ com.yetanalytics/project-persephone {:mvn/version "0.9.0"} - [Library](doc/library.md): How to use the library/API functions - [CLI](doc/cli.md): How to run the `validate` and `match` commands +- [Webserver](doc/server.md): How to start up and run a webserver ## How It Works diff --git a/bin/server.sh b/bin/server.sh new file mode 100644 index 0000000..6a7a398 --- /dev/null +++ b/bin/server.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +java -server -jar server.jar $@ diff --git a/deps.edn b/deps.edn index d84a195..5613a78 100644 --- a/deps.edn +++ b/deps.edn @@ -21,8 +21,37 @@ {:cli {:extra-paths ["src/cli"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} - org.clojure/tools.cli {:mvn/version "1.0.206"} - cheshire/cheshire {:mvn/version "5.11.0"}}} + org.clojure/tools.cli {:mvn/version "1.0.206"}}} + :server + {:extra-paths ["src/server"] + :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} + org.clojure/tools.cli {:mvn/version "1.0.206"} + org.slf4j/slf4j-simple {:mvn/version "1.7.28"} + ;; pedestal.service + io.pedestal/pedestal.service + {:mvn/version "0.5.10" + :exclusions [org.msgpack/msgpack + cheshire/cheshire + ring/ring-core]} + cheshire/cheshire {:mvn/version "5.11.0"} + ring/ring-core {:mvn/version "1.10.0"} + ;; pedestal.jetty + io.pedestal/pedestal.jetty + {:mvn/version "0.5.10" + :exclusions [org.eclipse.jetty/jetty-server + org.eclipse.jetty/jetty-servlet + org.eclipse.jetty.alpn/alpn-api + org.eclipse.jetty/jetty-alpn-server + org.eclipse.jetty.http2/http2-server + org.eclipse.jetty.websocket/websocket-api + org.eclipse.jetty.websocket/websocket-servlet + org.eclipse.jetty.websocket/websocket-server]} + org.eclipse.jetty/jetty-server {:mvn/version "9.4.51.v20230217"} + org.eclipse.jetty/jetty-servlet {:mvn/version "9.4.51.v20230217"} + org.eclipse.jetty.alpn/alpn-api {:mvn/version "1.1.3.v20160715"} + org.eclipse.jetty/jetty-alpn-server {:mvn/version "9.4.51.v20230217"} + org.eclipse.jetty/jetty-alpn-java-server {:mvn/version "9.4.51.v20230217"} + org.eclipse.jetty.http2/http2-server {:mvn/version "9.4.51.v20230217"}}} :dev {:extra-paths ["src/dev"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} @@ -33,10 +62,16 @@ criterium/criterium {:mvn/version "0.4.6"} ; clj only com.taoensso/tufte {:mvn/version "2.2.0"}}} :test - {:extra-paths ["src/cli" "src/test" "src/gen" "test-resources"] + {:extra-paths ["src/cli" "src/server" "src/test" "test-resources"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} org.clojure/clojurescript {:mvn/version "1.10.764" :exclusions [org.clojure/data.json]} + ;; :server deps + io.pedestal/pedestal.service {:mvn/version "0.5.10"} + io.pedestal/pedestal.jetty {:mvn/version "0.5.10"} + ;; Superseeded by babashka/http-client but we cannot use that + ;; due to cljs shadowing the default `random-uuid` fn + babashka/babashka.curl {:mvn/version "0.1.2"} org.clojure/test.check {:mvn/version "1.1.0"} orchestra/orchestra {:mvn/version "2021.01.01-1"} olical/cljs-test-runner {:mvn/version "3.8.0" diff --git a/doc/server.md b/doc/server.md new file mode 100644 index 0000000..1750f77 --- /dev/null +++ b/doc/server.md @@ -0,0 +1,194 @@ +# Webserver + +Persephone features a webserver that can be used to validate or match Statements at the `POST /statements` endpoint. + +To use the server, first run `make bundle`, then `cd` into `target/bundle`. You will then be able to run `/bin/server.sh`, which will accept either the `validate` or `match` subcommand to start the server in either Statement Template validation or Pattern matching mode, respectively. The following table shows the top-level arguments to the server init command: + +| Command Argument | Default | Description +| :-- | :-- | :-- +| `-H, --host HOST` | `localhost` | The hostname of the webserver endpoint +| `-P, --port PORT` | `8080` | The port number of the webserver endpoint; must be between 0 and 65536 +| `-h, --help` | N/A | Display the top-level help guide + +The `validate` subcommand starts the server in validate mode, where any Statements sent to the `/statements` endpoint will undergo Template matching against the Profiles that the server was given on startup. If a Statement array is sent, only the last statement will be validated. The following table shows the arguments to `validate`: + +| Subcommand Argument | Description +| :-- | :-- +| `-p, --profile URI` | Profile URI filepath/location; must specify one or more. +| `-i, --template-id IRI` | IDs of Statement Templates to validate against; can specify zero or more. Filters out all Templates that are not included. +| `-a, --all-valid` | If set, any Statement is not considered valid unless it is valid against ALL Templates. Otherwise, a Statement only needs to be valid against at least one Template. +| `-c, --short-circuit` | If set, then print on only the first Template any Statement fails validation against.Otherwise, print for all Templates a Statement fails against. +| `-h, --help` | Display the 'validate' subcommand help menu. + +The `match` subcommand starts the server in match mode, where any Statements sent to the `/statements` endpoint will undergo Pattern matching against the Profiles that the server was given on startup. If a single Statement is sent, it is coerced into a Statement batch. The following table shows the arguments to `match`: + +| Subcommand Argument | Description +| :-- | :-- +| `-p, --profile URI` | Profile filepath/location; must specify one or more. +| `-i, --pattern-id IRI` | IDs of primary Patterns to match against; can specify zero or more. Filters out all Patterns that are not included. +| `-h, --help` | Display the 'match' subcommand help menu. + +In addition to the `POST /statements` endpoint, there is a `GET /health` endpoint that is used to perform a server health check: +``` +% curl -i 0.0.0.0:8080/health +``` +which will return an response with status `200 OK`: +```http +HTTP/1.1 200 OK +Date: Mon, 01 May 2023 17:25:32 GMT +Strict-Transport-Security: max-age=31536000; includeSubdomains +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +X-XSS-Protection: 1; mode=block +X-Download-Options: noopen +X-Permitted-Cross-Domain-Policies: none +Content-Security-Policy: object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:; +Content-Type: text/plain +Transfer-Encoding: chunked +``` +and a body `"OK"`. + +There is no `PUT` or `GET` versions of the `/statements` endpoint, unlike what is required in a learning record store. + +# Examples for validate mode + +For the first few examples, let us start a webserver in validate mode with a single Profile. Assume that we have already copied the contents of `test-profile` into the `target/bundle` directory. Running this command +``` +% ./bin/server.sh validate --profile sample_profiles/calibration.jsonld +``` +will start up a server in validate mode on `localhost:8080` with a single Profile set to validate against. + +To validate a single Statement against Templates in that Profile: +```bash +% curl -i localhost:8080/statements \ + -H "Content-Type: application/json" \ + -d @sample_statements/calibration_1.json +``` +This will return the following `204 No Content` response, indicating validation success: +```http +HTTP/1.1 204 No Content +Date: Mon, 01 May 2023 17:18:14 GMT +Strict-Transport-Security: max-age=31536000; includeSubdomains +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +X-XSS-Protection: 1; mode=block +X-Download-Options: noopen +X-Permitted-Cross-Domain-Policies: none +Content-Security-Policy: object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:; +``` + +We can also input a Statement array, e.g. `sample_statements/calibration_coll.json`, but only the last Statement in that array will be validated. + +If we try to validate an invalid Statement: +```bash +% curl -i localhost:8080/statements \ + -H "Content-Type: application/json" \ + -d @sample_statements/adl_1.json +``` +then we receive a `400 Bad Request` response: +```http +HTTP/1.1 400 Bad Request +Date: Mon, 01 May 2023 17:21:23 GMT +Strict-Transport-Security: max-age=31536000; includeSubdomains +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +X-XSS-Protection: 1; mode=block +X-Download-Options: noopen +X-Permitted-Cross-Domain-Policies: none +Content-Security-Policy: object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:; +Content-Type: application/edn +Transfer-Encoding: chunked +``` + +and an EDN response body that looks like the following: +```clojure +{:type :validation-failure + :contents {...}} +``` +where `:contents` is the return value of `persephone/validate-statement` with `:fn-type :errors`. + +We will also receive a `400 Bad Request` error if we input a completely invalid statement: +```bash +% curl -i localhost:8080/statements \ + -H "Content-Type: application/json" \ + -d '{"id": "not-a-statement"}' +``` +The response body will still contain `:type` and `:contents`, but `:type` will have the value `:invalid-statement` and `:contents` will be a Clojure spec error map. + +Similarly, if the request is invalid JSON, `:type` will have the value `:invalid-json`. + +Validation also works with two Profiles: +``` +% ./bin/server.sh validate \ + --profile sample_profiles/calibration.jsonld \ + --profile sample_profiles/catch.json +``` +as well as the `--template-id`, `--all-valid`, and `--short-circuit` flags. These work very similarly to how they work in the [CLI](cli.md#examples-for-persephone-validate). Note that you should take care not to include duplicate IDs or else you will receive an init error. + +# Examples for match mode + +In match mode, we will first start a webserver mode with a single Profile. Running this command +``` +% ./bin/server.sh match --profile sample_profiles/calibration.jsonld +``` +will start up a server in validate mode on `localhost:8080` with a single Profile set to perform Pattern matching against. + +To validate a Statement array against Templates in that Profile: +```bash +% curl -i localhost:8080/statements \ + -H "Content-Type: application/json" \ + -d @sample_statements/calibration_coll.json +``` +This will return a `204 No Content` response, indicating match success: +```http +HTTP/1.1 204 No Content +Date: Mon, 01 May 2023 17:23:09 GMT +Strict-Transport-Security: max-age=31536000; includeSubdomains +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +X-XSS-Protection: 1; mode=block +X-Download-Options: noopen +X-Permitted-Cross-Domain-Policies: none +Content-Security-Policy: object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:; +``` + +Likewise, we can match against a statement, in this case passing in the file `sample_statements/calibration_1.json` instead. Notice how similar the request body is to the equivalent request in validate mode. + +If we try to Pattern match a Statement sequence that cannot be matched (e.g. we reverse the order of the two Statements in `calibration_coll.json`), we will receive the following EDN `400 Bad Request` response: +```http +HTTP/1.1 400 Bad Request +Date: Mon, 01 May 2023 17:24:10 GMT +Strict-Transport-Security: max-age=31536000; includeSubdomains +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +X-XSS-Protection: 1; mode=block +X-Download-Options: noopen +X-Permitted-Cross-Domain-Policies: none +Content-Security-Policy: object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:; +Content-Type: application/edn +Transfer-Encoding: chunked +``` + +And the following response body: +```clojure +{:type :match-failure + :contents {...}} +``` +where `:contents` is the return value of `persephone/match-statement-batch`. + +If we have a match error, e.g. a missing Profile reference in category context activity IDs or an invalid subregistration, the `400 Bad Request` response body will be of the form +```clojure +{:type :match-error + :contents {:errors {...}}} +``` +where the `:errors` value is a map containing the error data. + +If we have a Statement syntax error, then `:type` will have the value `:invalid-statements` and `:contents` will be a Clojure spec error map. Similarly, if we have invalid JSON, then `:type` will be `:invalid-json`. + +As with validation, Pattern matching works with two or more Profiles: +``` +% ./bin/server.sh match \ + --profile sample_profiles/calibration.jsonld \ + --profile sample_profiles/catch.json +``` +though you should be careful not to include any duplicate Profile or Pattern IDs or else you will receive an error. diff --git a/src/build/com/yetanalytics/persephone/build.clj b/src/build/com/yetanalytics/persephone/build.clj index fd860a8..67fe24d 100644 --- a/src/build/com/yetanalytics/persephone/build.clj +++ b/src/build/com/yetanalytics/persephone/build.clj @@ -1,24 +1,26 @@ (ns com.yetanalytics.persephone.build (:require [clojure.tools.build.api :as b])) -(def valid-jar-names #{"cli"}) +(def valid-jar-names #{"cli" "server"}) -(def source-dirs ["src/main" "src/cli"]) +(defn- source-directories [jar-name] + ["src/main" (format "src/%s" jar-name)]) -(def class-dir "target/classes") +(defn- class-directory [jar-name] + (format "target/classes/%s/" jar-name)) -(def basis +(defn- project-basis [jar-name] (b/create-basis {:project "deps.edn" - :aliases [:cli]})) + :aliases [(keyword jar-name)]})) -(defn- uber-file [jar-name] +(defn- uberjar-file [jar-name] (format "target/bundle/%s.jar" jar-name)) -(defn- main-ns [jar-name] - (case jar-name - "cli" 'com.yetanalytics.persephone.cli)) +(defn- main-namespace [jar-name] + (symbol (format "com.yetanalytics.persephone.%s" jar-name))) (defn- validate-jar-name + "Return `jar` as a string if valid, throw otherwise." [jar] (let [jar-name (name jar)] (if (valid-jar-names jar-name) @@ -32,18 +34,24 @@ | Keyword Arg | Description | --- | --- - | `:cli` | Create `cli.jar` for the command line interface." + | `:cli` | Create `cli.jar` for the command line interface. + | `:server` | Create `server.jar` for the webserver." [{:keys [jar]}] - (let [jar-name (validate-jar-name jar)] + (let [jar-name (validate-jar-name jar) + src-dirs (source-directories jar-name) + class-dir (class-directory jar-name) + basis (project-basis jar-name) + uber-file (uberjar-file jar-name) + main-ns (main-namespace jar-name)] (b/copy-dir - {:src-dirs source-dirs + {:src-dirs src-dirs :target-dir class-dir}) (b/compile-clj {:basis basis - :src-dirs source-dirs + :src-dirs src-dirs :class-dir class-dir}) (b/uber {:basis basis :class-dir class-dir - :uber-file (uber-file jar-name) - :main (main-ns jar-name)}))) + :uber-file uber-file + :main main-ns}))) diff --git a/src/cli/com/yetanalytics/persephone/cli.clj b/src/cli/com/yetanalytics/persephone/cli.clj index 4713f6e..56e8c20 100644 --- a/src/cli/com/yetanalytics/persephone/cli.clj +++ b/src/cli/com/yetanalytics/persephone/cli.clj @@ -1,8 +1,8 @@ (ns com.yetanalytics.persephone.cli (:require [clojure.tools.cli :as cli] + [com.yetanalytics.persephone.utils.cli :as u] [com.yetanalytics.persephone.cli.match :as m] - [com.yetanalytics.persephone.cli.validate :as v] - [com.yetanalytics.persephone.cli.util.args :refer [printerr]]) + [com.yetanalytics.persephone.cli.validate :as v]) (:gen-class)) (def top-level-options @@ -39,5 +39,5 @@ (System/exit 1)) :else (do - (printerr (format "Unknown subcommand: %s" subcommand)) + (u/printerr (format "Unknown subcommand: %s" subcommand)) (System/exit 1))))) diff --git a/src/cli/com/yetanalytics/persephone/cli/match.clj b/src/cli/com/yetanalytics/persephone/cli/match.clj index d1e16dd..8f7537f 100644 --- a/src/cli/com/yetanalytics/persephone/cli/match.clj +++ b/src/cli/com/yetanalytics/persephone/cli/match.clj @@ -1,8 +1,7 @@ (ns com.yetanalytics.persephone.cli.match - (:require [com.yetanalytics.persephone :as per] - [com.yetanalytics.persephone.cli.util.args :as a] - [com.yetanalytics.persephone.cli.util.file :as f] - [com.yetanalytics.persephone.cli.util.spec :as s]) + (:require [clojure.tools.cli :as cli] + [com.yetanalytics.persephone :as per] + [com.yetanalytics.persephone.utils.cli :as u]) (:import [clojure.lang ExceptionInfo])) (def match-statements-options @@ -11,25 +10,23 @@ :id :profiles :missing "No Profiles specified." :multi true - :parse-fn f/read-profile - :validate [s/profile? s/profile-err-msg] - :update-fn (fnil conj [])] + :parse-fn u/read-profile + :validate [u/profile? u/profile-err-msg] + :update-fn u/conj-argv] ["-i" "--pattern-id IRI" "IDs of primary Patterns to match against; can specify zero or more. Filters out all Patterns that are not included." :id :pattern-ids :multi true - :validate [s/iri? s/iri-err-msg] - :update-fn (fnil conj [])] + :validate [u/iri? u/iri-err-msg] + :update-fn u/conj-argv] ["-s" "--statement URI" "Statement filepath/location; must specify one or more. Accepts arrays of Statements." :id :statements :missing "No Statements specified." :multi true - :parse-fn f/read-statement - :validate [s/statements? s/statements-err-msg] - :update-fn (fn [xs s] - (let [xs (or xs [])] - (if (vector? s) (into xs s) (conj xs s))))] + :parse-fn u/read-statement + :validate [u/statements? u/statements-err-msg] + :update-fn u/conj-argv-or-array] ["-n" "--compile-nfa" (str "If set, compiles the Patterns into a non-deterministic finite " "automaton (NFA) instead of a deterministic one, allowing for " @@ -54,7 +51,7 @@ (not (boolean (or (-> state-m :error) (-> state-m :rejects not-empty))))) (catch ExceptionInfo e - (s/handle-asserts e) + (u/print-assert-errors e) false))) (defn match @@ -62,7 +59,8 @@ and return `false` if errors or failures exist, `true` if match passes or if the `--help` argument was present." [arglist] - (let [options (a/handle-args arglist match-statements-options)] + (let [parsed (cli/parse-opts arglist match-statements-options) + options (u/handle-parsed-args parsed)] (cond (= :help options) true (= :error options) false diff --git a/src/cli/com/yetanalytics/persephone/cli/util/args.clj b/src/cli/com/yetanalytics/persephone/cli/util/args.clj deleted file mode 100644 index 3872b46..0000000 --- a/src/cli/com/yetanalytics/persephone/cli/util/args.clj +++ /dev/null @@ -1,31 +0,0 @@ -(ns com.yetanalytics.persephone.cli.util.args - (:require [clojure.tools.cli :as cli])) - -(defn printerr - "Print the `error-messages` vector line-by-line to stderr." - [& error-messages] - (binding [*out* *err*] - (run! println error-messages)) - (flush)) - -(defn handle-args - "Parse `args` based on `cli-options` (which should follow the tools.cli - specification) and either return `:error`, print `--help` command and - return `:help`, or return the parsed `options` map." - [args cli-options] - (let [{:keys [options summary errors]} - (cli/parse-opts args cli-options) - {:keys [help]} - options] - (cond - ;; Display help menu and exit - help - (do (println summary) - :help) - ;; Display error message and exit - (not-empty errors) - (do (apply printerr errors) - :error) - ;; Do the things - :else - options))) diff --git a/src/cli/com/yetanalytics/persephone/cli/util/file.clj b/src/cli/com/yetanalytics/persephone/cli/util/file.clj deleted file mode 100644 index 42eb7d3..0000000 --- a/src/cli/com/yetanalytics/persephone/cli/util/file.clj +++ /dev/null @@ -1,10 +0,0 @@ -(ns com.yetanalytics.persephone.cli.util.file - (:require [com.yetanalytics.persephone.utils.json :as json])) - -(defn read-profile - [profile-filename] - (json/coerce-profile (slurp profile-filename))) - -(defn read-statement - [statement-filename] - (json/coerce-statement (slurp statement-filename))) diff --git a/src/cli/com/yetanalytics/persephone/cli/util/spec.clj b/src/cli/com/yetanalytics/persephone/cli/util/spec.clj deleted file mode 100644 index ecf261f..0000000 --- a/src/cli/com/yetanalytics/persephone/cli/util/spec.clj +++ /dev/null @@ -1,54 +0,0 @@ -(ns com.yetanalytics.persephone.cli.util.spec - (:require [clojure.spec.alpha :as s] - [xapi-schema.spec :as xs] - [com.yetanalytics.pan.axioms :as ax] - [com.yetanalytics.pan.errors :as perr] - [com.yetanalytics.persephone.utils.asserts :as assert] - [com.yetanalytics.persephone.cli.util.args :refer [printerr]])) - -(defn iri? [x] (s/valid? ::ax/iri x)) - -(def iri-err-msg "Must be a valid IRI.") - -;; Super-basic profile check; most work is done during compilation -(defn profile? [p] (map? p)) - -(defn profile-err-msg [_] "Must be a valid JSON object.") - -(defn statement? [s] (s/valid? ::xs/statement s)) - -(defn statement-err-msg [s] (s/explain-str ::xs/statement s)) - -(defn statements? [s] - (s/or :single (s/valid? ::xs/statement s) - :multiple (s/valid? ::xs/statements s))) - -(defn statements-err-msg [s] - (if (vector? s) - (s/explain-str ::xs/statements s) - (s/explain-str ::xs/statement s))) - -(defn handle-asserts - "Handle all possible assertions given an ExceptionInfo `ex` thrown from - the `persephone.utils.asserts` namespace." - [ex] - (case (or (-> ex ex-data :kind) - (-> ex ex-data :type)) - ::assert/invalid-profile - (printerr "Profile errors are present." - (-> ex ex-data :errors perr/errors->string)) - ::assert/invalid-template - (printerr "Template errors are present." - (-> ex ex-data :errors s/explain-printer with-out-str)) - ::assert/no-templates - (printerr "Compilation error: no Statement Templates to validate against") - ::assert/no-patterns - (printerr "Compilation error: no Patterns to match against, or one or more Profiles lacks Patterns") - ::assert/non-unique-profile-ids - (printerr "ID error: Profile IDs are not unique") - ::assert/non-unique-template-ids - (printerr "ID error: Template IDs are not unique") - ::assert/non-unique-pattern-ids - (printerr "ID error: Pattern IDs are not unique") - ;; else - (throw ex))) diff --git a/src/cli/com/yetanalytics/persephone/cli/validate.clj b/src/cli/com/yetanalytics/persephone/cli/validate.clj index d6f3caf..1e8812a 100644 --- a/src/cli/com/yetanalytics/persephone/cli/validate.clj +++ b/src/cli/com/yetanalytics/persephone/cli/validate.clj @@ -1,8 +1,7 @@ (ns com.yetanalytics.persephone.cli.validate - (:require [com.yetanalytics.persephone :as per] - [com.yetanalytics.persephone.cli.util.args :as a] - [com.yetanalytics.persephone.cli.util.file :as f] - [com.yetanalytics.persephone.cli.util.spec :as s] + (:require [clojure.tools.cli :as cli] + [com.yetanalytics.persephone :as per] + [com.yetanalytics.persephone.utils.cli :as u] [com.yetanalytics.persephone.template.errors :as errs] [com.yetanalytics.persephone.template.statement-ref :as sref]) (:import [clojure.lang ExceptionInfo])) @@ -13,21 +12,21 @@ :id :profiles :missing "No Profiles specified." :multi true - :parse-fn f/read-profile - :validate [s/profile? s/profile-err-msg] - :update-fn (fnil conj [])] + :parse-fn u/read-profile + :validate [u/profile? u/profile-err-msg] + :update-fn u/conj-argv] ["-i" "--template-id IRI" "IDs of Statement Templates to validate against; can specify zero or more. Filters out all Templates that are not included." :id :template-ids :multi true - :validate [s/iri? s/iri-err-msg] - :update-fn (fnil conj [])] + :validate [u/iri? u/iri-err-msg] + :update-fn u/conj-argv] ["-s" "--statement URI" "Statement filepath/location; must specify one." :id :statement :missing "No Statement specified." - :parse-fn f/read-statement - :validate [s/statement? s/statement-err-msg]] + :parse-fn u/read-statement + :validate [u/statement? u/statement-err-msg]] ["-e" "--extra-statements URI" (str "Extra Statement batch filepath/location; can specify zero or more " "and accepts arrays of Statements. " @@ -36,11 +35,9 @@ "and its Template exists in a provided Profile.") :id :extra-statements :multi true - :parse-fn f/read-statement - :validate [s/statements? s/statements-err-msg] - :update-fn (fn [xs s] - (let [xs (or xs [])] - (if (vector? s) (into xs s) (conj xs s))))] + :parse-fn u/read-statement + :validate [u/statements? u/statements-err-msg] + :update-fn u/conj-argv-or-array] ["-a" "--all-valid" (str "If set, the Statement is not considered valid unless it is valid " "against ALL Templates. " @@ -85,7 +82,7 @@ (dorun (->> error-res vals (apply concat) errs/print-errors))) (empty? error-res)) (catch ExceptionInfo e - (s/handle-asserts e) + (u/print-assert-errors e) false))) (defn validate @@ -93,7 +90,8 @@ validation errors and return `false` if errors exist, `true` if validation passes or the `--help` argument was present." [arglist] - (let [options (a/handle-args arglist validate-statement-options)] + (let [parsed (cli/parse-opts arglist validate-statement-options) + options (u/handle-parsed-args parsed)] (cond (= :help options) true (= :error options) false diff --git a/src/main/com/yetanalytics/persephone/utils/cli.clj b/src/main/com/yetanalytics/persephone/utils/cli.clj new file mode 100644 index 0000000..1ea2bd1 --- /dev/null +++ b/src/main/com/yetanalytics/persephone/utils/cli.clj @@ -0,0 +1,122 @@ +(ns com.yetanalytics.persephone.utils.cli + "Clojure-only namespace for CLI-specific utilities, used by the + `:cli` and `:server` aliases instead of the general API." + (:require [clojure.spec.alpha :as s] + [xapi-schema.spec :as xs] + [com.yetanalytics.pan.axioms :as ax] + [com.yetanalytics.pan.errors :as perr] + [com.yetanalytics.persephone.utils.asserts :as assert] + [com.yetanalytics.persephone.utils.json :as json])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; File reading +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn read-profile + [profile-filename] + (json/coerce-profile (slurp profile-filename))) + +(defn read-statement + [statement-filename] + (json/coerce-statement (slurp statement-filename))) + +(defn conj-argv + "Function to conj a non-array-valued arg `v` to pre-existing `values`." + [values v] + (let [values (or values [])] + (conj values v))) + +(defn conj-argv-or-array + "Function to conj an array-valued or non-array-valued arg `v` to + pre-existing `values`. An array-valued `v` is presumed to be a vector + (as opposed to a list or lazy seq)." + [values v] + (let [values (or values [])] + (if (vector? v) (into values v) (conj values v)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Validation +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn iri? [x] (s/valid? ::ax/iri x)) + +(defn iri-err-msg [_] "Must be a valid IRI.") + +;; Super-basic profile check; most work is done during compilation +(defn profile? [p] (map? p)) + +(defn profile-err-msg [_] "Must be a valid JSON object.") + +(defn statement? [s] (s/valid? ::xs/statement s)) + +(defn statement-err-data [s] (s/explain-data ::xs/statement s)) + +(defn statement-err-msg [s] (s/explain-str ::xs/statement s)) + +(defn statements? [s] + (s/or :single (s/valid? ::xs/statement s) + :multiple (s/valid? ::xs/statements s))) + +(defn statements-err-data [s] (s/explain-data ::xs/statements s)) + +(defn statements-err-msg [s] + (if (vector? s) + (s/explain-str ::xs/statements s) + (s/explain-str ::xs/statement s))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Error printing +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn printerr + "Print the `error-messages` vector line-by-line to stderr." + [& error-messages] + (binding [*out* *err*] + (run! println error-messages)) + (flush)) + +(defn print-assert-errors + "Handle all possible assertions given an ExceptionInfo `ex` thrown from + the `persephone.utils.asserts` namespace. Print assertion exceptions to + stderr using `printerr`, or re-throw if not recognized." + [ex] + (case (or (-> ex ex-data :kind) + (-> ex ex-data :type)) + ::assert/invalid-profile + (printerr "Profile errors are present." + (-> ex ex-data :errors perr/errors->string)) + ::assert/invalid-template + (printerr "Template errors are present." + (-> ex ex-data :errors s/explain-printer with-out-str)) + ::assert/no-templates + (printerr "Compilation error: no Statement Templates to validate against") + ::assert/no-patterns + (printerr "Compilation error: no Patterns to match against, or one or more Profiles lacks Patterns") + ::assert/non-unique-profile-ids + (printerr "ID error: Profile IDs are not unique") + ::assert/non-unique-template-ids + (printerr "ID error: Template IDs are not unique") + ::assert/non-unique-pattern-ids + (printerr "ID error: Pattern IDs are not unique") + ;; else + (throw ex))) + +(defn handle-parsed-args + "Given the return value of `cli/parse-opts`, return either `:error`, + `:help` or the parsed `options` map. In the `:error` case, print the + CLI errors to stderr, and in the `:help` case, print the `--help` + command result to stdout." + [{:keys [options summary errors]}] + (let [{:keys [help]} options] + (cond + ;; Display help menu and exit + help + (do (println summary) + :help) + ;; Display error message and exit + (not-empty errors) + (do (apply printerr errors) + :error) + ;; Do the things + :else + options))) diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj new file mode 100644 index 0000000..0884feb --- /dev/null +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -0,0 +1,179 @@ +(ns com.yetanalytics.persephone.server + (:require [clojure.tools.cli :as cli] + [io.pedestal.interceptor :as i] + [io.pedestal.http :as http] + [io.pedestal.http.body-params :as body-params] + [com.yetanalytics.persephone.utils.cli :as u] + [com.yetanalytics.persephone.server.validate :as v] + [com.yetanalytics.persephone.server.match :as m]) + (:gen-class)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Interceptors +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def health + (i/interceptor + {:name ::health + :enter (fn [context] + (assoc context :response {:status 200 :body "OK"}))})) + +(def request-body + "Interceptor that performs parsing for request bodies `application/json` + content type, keeping keys as strings instead of keywordizing them. + Returns a 400 error for invalid JSON." + (-> (body-params/body-params + {#"^application/json" (body-params/custom-json-parser :key-fn str)}) + (assoc :error + (fn [context _] + (assoc context :response {:status 400 + :body {:type :invalid-json}}))))) + +(def validate + (i/interceptor + {:name ::validate + :enter + (fn validate [context] + (let [statements (get-in context [:request :json-params]) + statement (if (sequential? statements) + (last statements) + statements) + stmt-err (u/statement-err-data statement) + stmt-err? (some? stmt-err) + err-res (and (not stmt-err?) + (v/validate statement)) + valid-err? (and (not stmt-err?) + (some? err-res)) + response (cond + stmt-err? {:status 400 + :body {:type :invalid-statement + :contents stmt-err}} + valid-err? {:status 400 + :body {:type :validation-failure + :contents err-res}} + :else {:status 204})] + (assoc context :response response)))})) + +(def match + (i/interceptor + {:name ::match + :enter + (fn match [context] + (let [statements (get-in context [:request :json-params]) + statements (if (sequential? statements) + statements + [statements]) + stmts-err (u/statements-err-data statements) + stmts-err? (some? stmts-err) + state-map (and (not stmts-err?) + (m/match statements)) + match-err? (and (not stmts-err?) + (-> state-map :error some?)) + match-fail? (and (not stmts-err?) + (-> state-map :rejects not-empty boolean)) + response (cond + stmts-err? {:status 400 + :body {:type :invalid-statements + :contents stmts-err}} + match-err? {:status 400 + :body {:type :match-error + :contents state-map}} + match-fail? {:status 400 + :body {:type :match-failure + :contents state-map}} + :else {:status 204})] + (assoc context :response response)))})) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Server Settings + Create +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- main-interceptor [mode-k] + (case mode-k + :validate validate + :match match)) + +(defn- routes [mode-k] + (let [main-intercept (main-interceptor mode-k)] + #{["/health" + :get [health] + :route-name :server/health] + ["/statements" + :post [request-body + main-intercept] + :route-name :server/statements]})) + +(defn start-server [mode-k host port] + (let [routes-set (routes mode-k) + server-map {::http/routes routes-set + ::http/type :jetty + ::http/allowed-origins [] + ::http/host host + ::http/port port + ::http/join? false}] + (http/start (http/create-server server-map)))) + +(defn stop-server [server] + (http/stop server)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Server Init CLI +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def top-level-options + [["-H" "--host HOST" "The hostname of the webserver endpoint" + :id :host + :default "localhost"] + ["-P" "--port PORT" "The port number of the webserver endpoint; must be between 0 and 65536" + :id :port + :default 8080 + :parse-fn #(Integer/parseInt %) + :validate [#(< 0 % 0x10000) "Must be an integer between 0 and 65536."]] + ["-h" "--help" "Display the top-level help guide"]]) + +(defn- top-level-summary [option-specs] + (str "Usage 'server [--host|-H HOST] [--port|-P PORT] [--help|-h] '\n" + "\n" + "where the subcommand can be one of the following:\n" + " validate Start a webserver that performs Statement Template validation on Statement requests\n" + " match Start a webserver that performs Pattern matching on Statement batch requests\n" + "\n" + "The 'server' command has the following optional arguments, along with defaults:\n" + (cli/summarize option-specs) "\n" + "\n" + "Run 'server --help' for details on each subcommand.")) + +(defn -main [& args] + (let [{:keys [options summary arguments]} + (cli/parse-opts args top-level-options + :in-order true + :summary-fn top-level-summary) + {:keys [host port help]} + options + [subcommand & rest] + arguments] + (cond + help + (do (println summary) + (System/exit 0)) + (= "validate" subcommand) + (let [k (v/compile-templates! rest)] + (cond + (= :help k) + (System/exit 0) + (= :error k) + (System/exit 1) + :else + (start-server :validate host port))) + (= "match" subcommand) + (let [k (m/compile-patterns! rest)] + (cond + (= :help k) + (System/exit 0) + (= :error k) + (System/exit 1) + :else + (start-server :match host port))) + :else + (do (u/printerr (format "Unknown subcommand: %s" subcommand)) + (System/exit 1))))) diff --git a/src/server/com/yetanalytics/persephone/server/match.clj b/src/server/com/yetanalytics/persephone/server/match.clj new file mode 100644 index 0000000..1958114 --- /dev/null +++ b/src/server/com/yetanalytics/persephone/server/match.clj @@ -0,0 +1,57 @@ +(ns com.yetanalytics.persephone.server.match + (:require [clojure.tools.cli :as cli] + [com.yetanalytics.persephone :as per] + [com.yetanalytics.persephone.utils.cli :as u]) + (:import [clojure.lang ExceptionInfo])) + +;; TODO: There is no --compile-nfa flag since there is no trace printing, and +;; the trace is not present in the match response. + +(def match-statements-options + [["-p" "--profile URI" + "Profile filepath/location; must specify one or more." + :id :profiles + :missing "No Profiles specified." + :multi true + :parse-fn u/read-profile + :validate [u/profile? u/profile-err-msg] + :update-fn u/conj-argv] + ["-i" "--pattern-id IRI" + (str "IDs of primary Patterns to match against; can specify zero or more. " + "Filters out all Patterns that are not included.") + :id :pattern-ids + :multi true + :validate [u/iri? u/iri-err-msg] + :update-fn u/conj-argv] + ["-h" "--help" "Display the 'match' subcommand help menu."]]) + +(defonce match-ref + (atom {:matchers nil})) + +(defn compile-patterns! + "Parse `arglist`, compile Profiles into FSMs, and store them in-memory. + Return either `:help`, `:error`, or `nil`." + [arglist] + (let [parsed (cli/parse-opts arglist match-statements-options) + options (u/handle-parsed-args parsed)] + (if (keyword? options) + options + (try (let [{:keys [profiles pattern-ids compile-nfa]} + options + matchers + (per/compile-profiles->fsms + profiles + :compile-nfa? compile-nfa + :selected-patterns (not-empty pattern-ids))] + (swap! match-ref assoc :matchers matchers) + true) + (catch ExceptionInfo e + (u/print-assert-errors e) + :error))))) + +(defn match + "Perform validation on `statements` against the FSM matchers that + are stored in-memory in the webserver." + [statements] + (let [{:keys [matchers]} @match-ref] + (per/match-statement-batch matchers nil statements))) diff --git a/src/server/com/yetanalytics/persephone/server/validate.clj b/src/server/com/yetanalytics/persephone/server/validate.clj new file mode 100644 index 0000000..590d573 --- /dev/null +++ b/src/server/com/yetanalytics/persephone/server/validate.clj @@ -0,0 +1,77 @@ +(ns com.yetanalytics.persephone.server.validate + (:require [clojure.tools.cli :as cli] + [com.yetanalytics.persephone :as per] + [com.yetanalytics.persephone.utils.cli :as u]) + (:import [clojure.lang ExceptionInfo])) + +;; TODO: Currently no way to provide Statement Ref property matching since +;; that requires a stream of statements to come from somewhere, which for the +;; webserver is during runtime, not compile-time. + +(def validate-statement-options + [["-p" "--profile URI" + "Profile URI filepath/location; must specify one or more." + :id :profiles + :missing "No Profiles specified." + :multi true + :parse-fn u/read-profile + :validate [u/profile? u/profile-err-msg] + :update-fn u/conj-argv] + ["-i" "--template-id IRI" + (str "IDs of Statement Templates to validate against; can specify zero or more. " + "Filters out all Templates that are not included.") + :id :template-ids + :multi true + :validate [u/iri? u/iri-err-msg] + :update-fn u/conj-argv] + ["-a" "--all-valid" + (str "If set, any Statement is not considered valid unless it is valid " + "against ALL Templates. " + "Otherwise, a Statement only needs to be valid against at least one Template.") + :id :all-valid] + ["-c" "--short-circuit" + (str "If set, then print on only the first Template any Statement fails " + "validation against." + "Otherwise, print for all Templates a Statement fails against.") + :id :short-circuit] + ["-h" "--help" "Display the 'validate' subcommand help menu."]]) + +(defonce validator-ref + (atom {:validators nil + :all-valid? false + :short-circuit? false})) + +(defn compile-templates! + "Parse `arglist`, compile Profiles into Template validators, and store them + in-memory. Returns either `:help`, `:error`, or `nil`." + [arglist] + (let [parsed (cli/parse-opts arglist validate-statement-options) + options (u/handle-parsed-args parsed)] + (if (keyword? options) + options + (try (let [{:keys [profiles template-ids all-valid short-circuit]} + options + validators + (per/compile-profiles->validators + profiles + :selected-templates (not-empty template-ids))] + (swap! validator-ref + assoc + :validators validators + :all-valid? all-valid + :short-circuit? short-circuit) + nil) + (catch ExceptionInfo e + (u/print-assert-errors e) + :error))))) + +(defn validate + "Perform validation on `statement` against the Template validators that + are stored in-memory in the webserver." + [statement] + (let [{:keys [validators all-valid? short-circuit?]} @validator-ref] + (per/validate-statement validators + statement + :fn-type :errors + :all-valid? all-valid? + :short-circuit? short-circuit?))) diff --git a/src/test/com/yetanalytics/persephone_test/cli_test/match_test.clj b/src/test/com/yetanalytics/persephone_test/cli_test/match_test.clj index 642c21c..df2034b 100644 --- a/src/test/com/yetanalytics/persephone_test/cli_test/match_test.clj +++ b/src/test/com/yetanalytics/persephone_test/cli_test/match_test.clj @@ -95,7 +95,7 @@ Pattern path: :result :print))) (with-err-str (match (list "-p" statement-uri "-s" statement-uri))))) - (is (= (str "ID error: Profile IDs are not unique\n") + (is (= "ID error: Profile IDs are not unique\n" (with-err-str (match (list "-p" profile-uri "-p" profile-uri "-s" statement-uri))))) diff --git a/src/test/com/yetanalytics/persephone_test/server_test/match_test.clj b/src/test/com/yetanalytics/persephone_test/server_test/match_test.clj new file mode 100644 index 0000000..acc8b28 --- /dev/null +++ b/src/test/com/yetanalytics/persephone_test/server_test/match_test.clj @@ -0,0 +1,134 @@ +(ns com.yetanalytics.persephone-test.server-test.match-test + (:require [clojure.test :refer [deftest testing is]] + [clojure.edn :as edn] + [babashka.curl :as curl] + [com.yetanalytics.pan :as pan] + [com.yetanalytics.persephone.server :as server] + [com.yetanalytics.persephone.server.match :as m]) + (:require [com.yetanalytics.persephone-test.test-utils :refer [with-err-str]])) + +(def profile-uri "test-resources/sample_profiles/calibration.jsonld") + +(def pattern-id "https://xapinet.org/xapi/yet/calibration/v1/patterns#pattern-1") + +(def statement-1 + (slurp "test-resources/sample_statements/calibration_1.json")) + +(def statement-2 + (slurp "test-resources/sample_statements/calibration_2.json")) + +(def statement-bad + (slurp "test-resources/sample_statements/adl_1.json")) + +(defmacro test-match-server + [desc-string arglist & body] + `(testing ~desc-string + (m/compile-patterns! ~arglist) + (let [server# (server/start-server :match "localhost" 8080)] + (try ~@body + (catch Exception e# + (.printStackTrace e#))) + (server/stop-server server#) + nil))) + +(defn- post-map [body] + {:headers {"Content-Type" "application/json"} + :body body + :throw false}) + +(deftest match-init-test + (testing "Help menu" + (is (string? (with-out-str (m/compile-patterns! '("--help")))))) + (testing "Init errors" + (is (= "No Profiles specified.\n" + (with-err-str (m/compile-patterns! '())))) + (is (= (str "Error while parsing option \"-p non-existent.json\": " + "java.io.FileNotFoundException: non-existent.json (No such file or directory)\n" + "No Profiles specified.\n") + (with-err-str + (m/compile-patterns! + (list "-p" "non-existent.json"))))) + (is (= (str "Profile errors are present.\n" + (with-out-str + (pan/validate-profile + (pan/json-profile->edn + (slurp "test-resources/sample_statements/calibration_1.json")) + :result :print))) + (with-err-str + (m/compile-patterns! + (list "-p" "test-resources/sample_statements/calibration_1.json"))))) + (is (= "ID error: Profile IDs are not unique\n" + (with-err-str + (m/compile-patterns! + (list "-p" profile-uri "-p" profile-uri))))) + (is (= "Compilation error: no Patterns to match against, or one or more Profiles lacks Patterns\n" + (with-err-str + (m/compile-patterns! + (list "-p" profile-uri "-i" "http://fake-pattern.com"))))))) + +(deftest match-test + (test-match-server + "Basic pattern matching w/ one profile:" + (list "--profile" profile-uri) + (testing "health check" + (let [{:keys [status body]} + (curl/get "localhost:8080/health")] + (is (= 200 status)) + (is (= "OK" body)))) + (testing "match passes" + (let [{:keys [status]} + (curl/post "localhost:8080/statements" + (post-map statement-1))] + (is (= 204 status))) + (let [{:keys [status]} + (curl/post "localhost:8080/statements" + (post-map (str "[" statement-1 "," statement-2 "]")))] + (is (= 204 status)))) + (testing "match fails" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map (str "[" statement-2 "," statement-1 "]")))] + (is (= 400 status)) + (is (= :match-failure + (-> body edn/read-string :type))) + (is (= {:accepts [] + :rejects [[:no-registration pattern-id]] + :states-map {:no-registration {pattern-id #{}}}} + (-> body edn/read-string :contents))))) + (testing "match errors out" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map statement-bad))] + (is (= 400 status)) + (is (= :match-error + (-> body edn/read-string :type))))) + (testing "statement does not conform to spec" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map "{\"id\": \"not-a-statement\"}"))] + (is (= 400 status)) + (is (= :invalid-statements + (-> body edn/read-string :type))))) + (testing "invalid JSON" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map "{\"id\":"))] + (is (= 400 status)) + (is (= :invalid-json + (-> body edn/read-string :type))))) + (testing "invalid Content-Type" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + {:headers {"Content-Type" "application/edn"} + :body statement-1 + :throw false})] + (is (= 400 status)) ; ideally should be 415 Unsupported Media Type + (is (= :invalid-statements + (-> body edn/read-string :type)))))) + (test-match-server + "Pattern matching works w/ two profiles" + (list "-p" profile-uri "-p" "test-resources/sample_profiles/catch.json") + (let [{:keys [status]} + (curl/post "localhost:8080/statements" + (post-map (str "[" statement-1 "," statement-2 "]")))] + (is (= 204 status))))) diff --git a/src/test/com/yetanalytics/persephone_test/server_test/validate_test.clj b/src/test/com/yetanalytics/persephone_test/server_test/validate_test.clj new file mode 100644 index 0000000..013d988 --- /dev/null +++ b/src/test/com/yetanalytics/persephone_test/server_test/validate_test.clj @@ -0,0 +1,159 @@ +(ns com.yetanalytics.persephone-test.server-test.validate-test + (:require [clojure.test :refer [deftest testing is]] + [clojure.edn :as edn] + [babashka.curl :as curl] + [com.yetanalytics.pan :as pan] + [com.yetanalytics.persephone.server :as server] + [com.yetanalytics.persephone.server.validate :as v] + [com.yetanalytics.persephone-test.test-utils :refer [with-err-str]])) + +(def profile-uri "test-resources/sample_profiles/calibration.jsonld") + +(def template-1-id "https://xapinet.org/xapi/yet/calibration/v1/templates#activity-1") +(def template-2-id "https://xapinet.org/xapi/yet/calibration/v1/templates#activity-2") +(def template-3-id "https://xapinet.org/xapi/yet/calibration/v1/templates#activity-3") + +(def statement + (slurp "test-resources/sample_statements/calibration_1.json")) + +(def statement-bad + (slurp "test-resources/sample_statements/adl_1.json")) + +(defmacro test-validate-server + [desc-string arglist & body] + `(testing ~desc-string + (v/compile-templates! ~arglist) + (let [server# (server/start-server :validate "localhost" 8080)] + (try ~@body + (catch Exception e# + (.printStackTrace e#))) + (server/stop-server server#) + nil))) + +(defn- post-map [body] + {:headers {"Content-Type" "application/json"} + :body body + :throw false}) + +(deftest validate-init-test + (testing "Help menu" + (is (string? (with-out-str (v/compile-templates! '("--help")))))) + (testing "Init errors" + (is (= "No Profiles specified.\n" + (with-err-str (v/compile-templates! '())))) + (is (= (str "Error while parsing option \"-p non-existent.json\": " + "java.io.FileNotFoundException: non-existent.json (No such file or directory)\n" + "No Profiles specified.\n") + (with-err-str + (v/compile-templates! + (list "-p" "non-existent.json"))))) + (is (= (str "Profile errors are present.\n" + (with-out-str + (pan/validate-profile + (pan/json-profile->edn + (slurp "test-resources/sample_statements/calibration_1.json")) + :result :print))) + (with-err-str + (v/compile-templates! + (list "-p" "test-resources/sample_statements/calibration_1.json"))))) + (is (= "ID error: Profile IDs are not unique\n" + (with-err-str + (v/compile-templates! + (list "-p" profile-uri "-p" profile-uri))))) + (is (= "Compilation error: no Statement Templates to validate against\n" + (with-err-str + (v/compile-templates! + (list "-p" profile-uri "-i" "http://fake-template.com"))))))) + +(deftest validate-test + (test-validate-server + "Basic validation w/ one profile:" + (list "--profile" profile-uri) + (testing "health check" + (let [{:keys [status body]} + (curl/get "localhost:8080/health")] + (is (= 200 status)) + (is (= "OK" body)))) + (testing "validation passes" + (let [{:keys [status]} + (curl/post "localhost:8080/statements" + (post-map statement))] + (is (= 204 status))) + (let [{:keys [status]} + (curl/post "localhost:8080/statements" + (post-map (str "[" statement "," statement "]")))] + (is (= 204 status)))) + (testing "validation fails" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map statement-bad))] + (is (= 400 status)) + (is (= :validation-failure + (-> body edn/read-string :type))) + (is (= #{template-1-id template-2-id template-3-id} + (-> body edn/read-string :contents keys set))))) + (testing "statement does not conform to spec" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map "{\"id\": \"not-a-statement\"}"))] + (is (= 400 status)) + (is (= :invalid-statement + (-> body edn/read-string :type))))) + (testing "invalid JSON" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map "{\"id\":"))] + (is (= 400 status)) + (is (= :invalid-json + (-> body edn/read-string :type))))) + (testing "invalid Content-Type" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + {:headers {"Content-Type" "application/edn"} + :body statement + :throw false})] + (is (= 400 status)) ; ideally should be 415 Unsupported Media Type + (is (= :invalid-statement + (-> body edn/read-string :type)))))) + (test-validate-server + "Validation works w/ two profiles" + (list "-p" profile-uri "-p" "test-resources/sample_profiles/catch.json") + (let [{:keys [status]} + (curl/post "localhost:8080/statements" + (post-map statement))] + (is (= 204 status)))) + (test-validate-server + "Validation with '--all-valid' flag" + (list "-p" profile-uri "--all-valid") + (testing "validation fails" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map statement))] + (is (= 400 status)) + (is (= :validation-failure + (-> body edn/read-string :type))) + (is (= #{template-2-id template-3-id} + (-> body edn/read-string :contents keys set)))))) + (test-validate-server + "Validation with '--all-valid' and '--short-circuit' flags" + (list "-p" profile-uri "--all-valid" "--short-circuit") + (testing "validation fails" + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map statement))] + (is (= 400 status)) + (is (= :validation-failure + (-> body edn/read-string :type))) + (is (= #{template-2-id} + (-> body edn/read-string :contents keys set)))))) + (test-validate-server + "Validation with '--template-id' flag" + (list "-p" profile-uri "-i" template-2-id "-i" template-3-id) + (let [{:keys [status body]} + (curl/post "localhost:8080/statements" + (post-map statement))] + (is (= 400 status)) + (is (= :validation-failure + (-> body edn/read-string :type))) + (is (= #{template-2-id template-3-id} + (-> body edn/read-string :contents keys set))))))