From fa6cdbff444ebdd9f2f2b881e3bf556e588f8750 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Tue, 25 Apr 2023 17:05:16 -0400 Subject: [PATCH 01/25] Sketch out server namespace and alias --- deps.edn | 5 ++ .../com/yetanalytics/persephone/server.clj | 63 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/server/com/yetanalytics/persephone/server.clj diff --git a/deps.edn b/deps.edn index 21e4bc1..4b6d585 100644 --- a/deps.edn +++ b/deps.edn @@ -23,6 +23,11 @@ :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"}}} + :server + {:extra-paths ["src/server"] + :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} + io.pedestal/pedestal.service {:mvn/version "0.5.10"} + io.pedestal/pedestal.jetty {:mvn/version "0.5.10"}}} :dev {:extra-paths ["src/dev"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj new file mode 100644 index 0000000..5d85d75 --- /dev/null +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -0,0 +1,63 @@ +(ns com.yetanalytics.persephone.server + (:require [io.pedestal.http :as http] + [io.pedestal.interceptor :as i] + [com.yetanalytics.persephone :as per]) + (:gen-class)) + +(defn- health + [] + (i/interceptor + {:name ::health + :enter (fn [_] {:status 200 :body "OK"})})) + +(def validate + (i/interceptor + {:name ::validate + :enter + (fn validate [context] + (let [{:keys [request]} context + {:keys [json-params]} request + {:keys [profiles + statement]} json-params + ;; Compile and validate + compiled (per/compile-profiles->validators profiles) + err-res (per/validate-statement compiled statement + :fn-type :errors)] + err-res))})) + +(def match + (i/interceptor + {:name ::match + :enter + (fn match [context] + (let [{:keys [request]} context + {:keys [json-params]} request + {:keys [profiles + statements]} json-params + ;; Compile and match + compiled (per/compile-profiles->fsms profiles) + state-m (per/match-statement-batch compiled nil statements)] + state-m))})) + +(def routes + #{["/health" + :get [health] + :route-name :server/health] + ["/validate" + :post [validate] + :route-name :server/validate] + ["/match" + :post [match] + :route-name :server/match]}) + +(defn- create-server [] + (http/create-server + {::http/routes routes + ::http/type :jetty + ::http/allowed-origins [] + ::http/host "localhost" + ::http/port 8080 + ::http/join? false})) + +(defn -main [& _] + (http/start (create-server))) From 258981958e23b6f12bd4816b2cb94c09036d3fab Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Wed, 26 Apr 2023 10:42:16 -0400 Subject: [PATCH 02/25] Polymorphize all build helper functions --- .../com/yetanalytics/persephone/build.clj | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/build/com/yetanalytics/persephone/build.clj b/src/build/com/yetanalytics/persephone/build.clj index fd860a8..163dea6 100644 --- a/src/build/com/yetanalytics/persephone/build.clj +++ b/src/build/com/yetanalytics/persephone/build.clj @@ -3,22 +3,24 @@ (def valid-jar-names #{"cli"}) -(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) @@ -34,16 +36,21 @@ | --- | --- | `:cli` | Create `cli.jar` for the command line interface." [{: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}))) From 76270d043b10d8a9d45601d1bd9599f395b978be Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Wed, 26 Apr 2023 12:34:46 -0400 Subject: [PATCH 03/25] Add build process for webserver --- Makefile | 5 ++++- bin/persephone-server.sh | 3 +++ deps.edn | 3 ++- src/build/com/yetanalytics/persephone/build.clj | 5 +++-- 4 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 bin/persephone-server.sh 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/bin/persephone-server.sh b/bin/persephone-server.sh new file mode 100644 index 0000000..3462f23 --- /dev/null +++ b/bin/persephone-server.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +java -server -jar server.jar diff --git a/deps.edn b/deps.edn index 4b6d585..d5fb0d0 100644 --- a/deps.edn +++ b/deps.edn @@ -27,7 +27,8 @@ {:extra-paths ["src/server"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} io.pedestal/pedestal.service {:mvn/version "0.5.10"} - io.pedestal/pedestal.jetty {:mvn/version "0.5.10"}}} + io.pedestal/pedestal.jetty {:mvn/version "0.5.10"} + org.slf4j/slf4j-simple {:mvn/version "1.7.28"}}} :dev {:extra-paths ["src/dev"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} diff --git a/src/build/com/yetanalytics/persephone/build.clj b/src/build/com/yetanalytics/persephone/build.clj index 163dea6..67fe24d 100644 --- a/src/build/com/yetanalytics/persephone/build.clj +++ b/src/build/com/yetanalytics/persephone/build.clj @@ -1,7 +1,7 @@ (ns com.yetanalytics.persephone.build (:require [clojure.tools.build.api :as b])) -(def valid-jar-names #{"cli"}) +(def valid-jar-names #{"cli" "server"}) (defn- source-directories [jar-name] ["src/main" (format "src/%s" jar-name)]) @@ -34,7 +34,8 @@ | 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) src-dirs (source-directories jar-name) From 1a78f77670365b11086fa87cfa7980f84c70bbba Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Wed, 26 Apr 2023 12:34:57 -0400 Subject: [PATCH 04/25] Fix health interceptor + assoc responses --- src/server/com/yetanalytics/persephone/server.clj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 5d85d75..776a9a6 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -4,11 +4,11 @@ [com.yetanalytics.persephone :as per]) (:gen-class)) -(defn- health - [] +(def health (i/interceptor {:name ::health - :enter (fn [_] {:status 200 :body "OK"})})) + :enter (fn [context] + (assoc context :response {:status 200 :body "OK"}))})) (def validate (i/interceptor @@ -23,7 +23,7 @@ compiled (per/compile-profiles->validators profiles) err-res (per/validate-statement compiled statement :fn-type :errors)] - err-res))})) + (assoc context :response {:status 200 :body err-res})))})) (def match (i/interceptor @@ -37,7 +37,7 @@ ;; Compile and match compiled (per/compile-profiles->fsms profiles) state-m (per/match-statement-batch compiled nil statements)] - state-m))})) + (assoc context :response {:status 200 :body state-m})))})) (def routes #{["/health" From 4714d79f88235ff9e8a892236c5a13266e53b3e0 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Wed, 26 Apr 2023 14:25:11 -0400 Subject: [PATCH 05/25] Give the server an init CLI to init with profiles --- bin/persephone-server.sh | 2 +- deps.edn | 1 + .../com/yetanalytics/persephone/server.clj | 87 +++++++++++++++---- .../yetanalytics/persephone/server/match.clj | 53 +++++++++++ .../yetanalytics/persephone/server/util.clj | 53 +++++++++++ .../persephone/server/validate.clj | 72 +++++++++++++++ 6 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 src/server/com/yetanalytics/persephone/server/match.clj create mode 100644 src/server/com/yetanalytics/persephone/server/util.clj create mode 100644 src/server/com/yetanalytics/persephone/server/validate.clj diff --git a/bin/persephone-server.sh b/bin/persephone-server.sh index 3462f23..6a7a398 100644 --- a/bin/persephone-server.sh +++ b/bin/persephone-server.sh @@ -1,3 +1,3 @@ #!/bin/sh -java -server -jar server.jar +java -server -jar server.jar $@ diff --git a/deps.edn b/deps.edn index d5fb0d0..299f2b0 100644 --- a/deps.edn +++ b/deps.edn @@ -26,6 +26,7 @@ :server {:extra-paths ["src/server"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} + org.clojure/tools.cli {:mvn/version "1.0.206"} io.pedestal/pedestal.service {:mvn/version "0.5.10"} io.pedestal/pedestal.jetty {:mvn/version "0.5.10"} org.slf4j/slf4j-simple {:mvn/version "1.7.28"}}} diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 776a9a6..be4aa19 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -1,9 +1,16 @@ (ns com.yetanalytics.persephone.server - (:require [io.pedestal.http :as http] + (:require [clojure.tools.cli :as cli] + [io.pedestal.http :as http] [io.pedestal.interceptor :as i] - [com.yetanalytics.persephone :as per]) + [com.yetanalytics.persephone.server.validate :as v] + [com.yetanalytics.persephone.server.match :as m] + [com.yetanalytics.persephone.server.util :as u]) (:gen-class)) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Interceptors +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (def health (i/interceptor {:name ::health @@ -15,14 +22,8 @@ {:name ::validate :enter (fn validate [context] - (let [{:keys [request]} context - {:keys [json-params]} request - {:keys [profiles - statement]} json-params - ;; Compile and validate - compiled (per/compile-profiles->validators profiles) - err-res (per/validate-statement compiled statement - :fn-type :errors)] + (let [statement (get-in context [:request :json-params :statement]) + err-res (v/validate statement)] (assoc context :response {:status 200 :body err-res})))})) (def match @@ -30,14 +31,13 @@ {:name ::match :enter (fn match [context] - (let [{:keys [request]} context - {:keys [json-params]} request - {:keys [profiles - statements]} json-params - ;; Compile and match - compiled (per/compile-profiles->fsms profiles) - state-m (per/match-statement-batch compiled nil statements)] - (assoc context :response {:status 200 :body state-m})))})) + (let [statements (get-in context [:request :json-params :statements]) + state-map (m/match statements)] + (assoc context :response {:status 200 :body state-map})))})) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Server Settings + Create +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def routes #{["/health" @@ -59,5 +59,54 @@ ::http/port 8080 ::http/join? false})) -(defn -main [& _] +(defn- start-server [] (http/start (create-server))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Server Init CLI +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def top-level-options + [["-h" "--help" "Display the top-level help guide."]]) + +(def top-level-summary + (str "Usage 'persephone-server ' or 'persephone-server [-h|--help]'\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" + "Run 'persephone-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 (fn [_] top-level-summary)) + [subcommand & rest] + arguments] + (cond + (:help options) + (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))) + (= "match" subcommand) + (let [k (m/compile-patterns! rest)] + (cond + (= :help k) + (System/exit 0) + (= :error k) + (System/exit 1) + :else + (start-server))) + :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..02d1c8f --- /dev/null +++ b/src/server/com/yetanalytics/persephone/server/match.clj @@ -0,0 +1,53 @@ +(ns com.yetanalytics.persephone.server.match + (:require [com.yetanalytics.persephone :as per] + [com.yetanalytics.persephone.server.util :as u])) + +(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 (fnil conj [])] + ["-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 (fnil conj [])] + ["-n" "--compile-nfa" + (str "If set, compiles the Patterns into a non-deterministic finite " + "automaton (NFA) instead of a deterministic one, allowing for " + "more detailed error traces at the cost of decreased performance.") + :id :compile-nfa] + ["-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." + [arglist] + (let [options (u/handle-args arglist match-statements-options)] + (if (keyword? options) + options + (let [{:keys [profiles pattern-ids compile-nfa]} + options + matchers + (per/compile-profiles->fsms + profiles + :validate-profiles? false + :compile-nfa? compile-nfa + :selected-patterns (not-empty pattern-ids))] + (swap! match-ref assoc :matchers matchers) + nil)))) + +(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/util.clj b/src/server/com/yetanalytics/persephone/server/util.clj new file mode 100644 index 0000000..fd68cb2 --- /dev/null +++ b/src/server/com/yetanalytics/persephone/server/util.clj @@ -0,0 +1,53 @@ +(ns com.yetanalytics.persephone.server.util + (:require [clojure.spec.alpha :as s] + [clojure.tools.cli :as cli] + [xapi-schema.spec :as xs] + [com.yetanalytics.pan :as pan] + [com.yetanalytics.pan.axioms :as ax] + [com.yetanalytics.persephone.utils.json :as json])) + +(defn read-profile [profile-filename] + (json/coerce-profile (slurp profile-filename))) + +(defn not-empty? [coll] (boolean (not-empty coll))) + +(defn iri? [x] (s/valid? ::ax/iri x)) + +(def iri-err-msg "Must be a valid IRI.") + +(defn profile? [p] (nil? (pan/validate-profile p))) + +(defn profile-err-msg [p] (pan/validate-profile p :result :string)) + +(defn statement? [s] (s/valid? ::xs/statement s)) + +(defn statement-err-msg [s] (s/explain-str ::xs/statement s)) + +(defn printerr + "Print the `err-messages` vector line-by-line to stderr." + [err-messages] + (binding [*out* *err*] + (run! println err-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 (printerr errors) + :error) + ;; Do the things + :else + options))) 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..fb23e08 --- /dev/null +++ b/src/server/com/yetanalytics/persephone/server/validate.clj @@ -0,0 +1,72 @@ +(ns com.yetanalytics.persephone.server.validate + (:require [com.yetanalytics.persephone :as per] + [com.yetanalytics.persephone.server.util :as u])) + +;; 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 (fnil conj [])] + ["-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 (fnil conj [])] + ["-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." + [arglist] + (let [options (u/handle-args arglist validate-statement-options)] + (if (keyword? options) + options + (let [{:keys [profiles template-ids all-valid short-circuit]} + options + validators + (per/compile-profiles->validators + profiles + :validate-profiles? false + :selected-templates (not-empty template-ids))] + (swap! validator-ref + assoc + :validators validators + :all-valid? all-valid + :short-circuit? short-circuit) + nil)))) + +(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?))) From f4a55fb0bbc000a462c1b4e14c36ae0d9aa3a63d Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Wed, 26 Apr 2023 15:26:05 -0400 Subject: [PATCH 06/25] Fix JSON params parsing in requests --- .../com/yetanalytics/persephone/server.clj | 21 ++++++++++++------- .../yetanalytics/persephone/server/util.clj | 3 +++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index be4aa19..868c758 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -1,7 +1,8 @@ (ns com.yetanalytics.persephone.server - (:require [clojure.tools.cli :as cli] - [io.pedestal.http :as http] - [io.pedestal.interceptor :as i] + (: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.server.validate :as v] [com.yetanalytics.persephone.server.match :as m] [com.yetanalytics.persephone.server.util :as u]) @@ -11,6 +12,12 @@ ;; Interceptors ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def request-body + "Interceptor that performs parsing for request bodies `application/json` + content type, keeping keys as strings instead of keywordizing them." + (body-params/body-params + {#"^application/json" (body-params/custom-json-parser :key-fn str)})) + (def health (i/interceptor {:name ::health @@ -22,7 +29,7 @@ {:name ::validate :enter (fn validate [context] - (let [statement (get-in context [:request :json-params :statement]) + (let [statement (get-in context [:request :json-params]) err-res (v/validate statement)] (assoc context :response {:status 200 :body err-res})))})) @@ -31,7 +38,7 @@ {:name ::match :enter (fn match [context] - (let [statements (get-in context [:request :json-params :statements]) + (let [statements (get-in context [:request :json-params]) state-map (m/match statements)] (assoc context :response {:status 200 :body state-map})))})) @@ -44,10 +51,10 @@ :get [health] :route-name :server/health] ["/validate" - :post [validate] + :post [request-body validate] :route-name :server/validate] ["/match" - :post [match] + :post [request-body match] :route-name :server/match]}) (defn- create-server [] diff --git a/src/server/com/yetanalytics/persephone/server/util.clj b/src/server/com/yetanalytics/persephone/server/util.clj index fd68cb2..b749849 100644 --- a/src/server/com/yetanalytics/persephone/server/util.clj +++ b/src/server/com/yetanalytics/persephone/server/util.clj @@ -9,6 +9,9 @@ (defn read-profile [profile-filename] (json/coerce-profile (slurp profile-filename))) +(defn read-statement [statement-filename] + (json/coerce-statement (slurp statement-filename))) + (defn not-empty? [coll] (boolean (not-empty coll))) (defn iri? [x] (s/valid? ::ax/iri x)) From cf581a7f059cddc017447a43a766fddec666a46b Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Wed, 26 Apr 2023 16:25:15 -0400 Subject: [PATCH 07/25] Use a single statements endpoint --- .../com/yetanalytics/persephone/server.clj | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 868c758..415ba67 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -29,8 +29,8 @@ {:name ::validate :enter (fn validate [context] - (let [statement (get-in context [:request :json-params]) - err-res (v/validate statement)] + (let [statements (get-in context [:request :json-params]) + err-res (v/validate statements)] (assoc context :response {:status 200 :body err-res})))})) (def match @@ -46,28 +46,26 @@ ;; Server Settings + Create ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def routes - #{["/health" - :get [health] - :route-name :server/health] - ["/validate" - :post [request-body validate] - :route-name :server/validate] - ["/match" - :post [request-body match] - :route-name :server/match]}) - -(defn- create-server [] - (http/create-server - {::http/routes routes - ::http/type :jetty - ::http/allowed-origins [] - ::http/host "localhost" - ::http/port 8080 - ::http/join? false})) +(defn routes [mode-k] + (let [main-intercept (case mode-k + :validate validate + :match match)] + #{["/health" + :get [health] + :route-name :server/health] + ["/statements" + :post [request-body main-intercept] + :route-name :server/statements]})) -(defn- start-server [] - (http/start (create-server))) +(defn- start-server [mode-k] + (let [routes-set (routes mode-k) + server-map {::http/routes routes-set + ::http/type :jetty + ::http/allowed-origins [] + ::http/host "localhost" + ::http/port 8080 + ::http/join? false}] + (http/start (http/create-server server-map)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Server Init CLI @@ -104,7 +102,7 @@ (= :error k) (System/exit 1) :else - (start-server))) + (start-server :validate))) (= "match" subcommand) (let [k (m/compile-patterns! rest)] (cond @@ -113,7 +111,7 @@ (= :error k) (System/exit 1) :else - (start-server))) + (start-server :match))) :else (do (u/printerr [(format "Unknown subcommand: %s" subcommand)]) (System/exit 1))))) From 49e0ca21aae0d8d8ea391a0ac35765d6f9debd8b Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 09:47:40 -0400 Subject: [PATCH 08/25] Distinguish between 204 and 400 responses --- .../com/yetanalytics/persephone/server.clj | 54 +++++++++++++++---- .../yetanalytics/persephone/server/util.clj | 4 +- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 415ba67..0f3cbb8 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -30,26 +30,62 @@ :enter (fn validate [context] (let [statements (get-in context [:request :json-params]) - err-res (v/validate statements)] - (assoc context :response {:status 200 :body err-res})))})) + statement (if (vector? 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]) - state-map (m/match statements)] - (assoc context :response {:status 200 :body state-map})))})) + (let [statements (get-in context [:request :json-params]) + statements (if (vector? 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 routes [mode-k] - (let [main-intercept (case mode-k - :validate validate - :match match)] +(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] diff --git a/src/server/com/yetanalytics/persephone/server/util.clj b/src/server/com/yetanalytics/persephone/server/util.clj index b749849..8dc8514 100644 --- a/src/server/com/yetanalytics/persephone/server/util.clj +++ b/src/server/com/yetanalytics/persephone/server/util.clj @@ -22,9 +22,9 @@ (defn profile-err-msg [p] (pan/validate-profile p :result :string)) -(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-err-data [s] (s/explain-data (s/coll-of ::xs/statement) s)) (defn printerr "Print the `err-messages` vector line-by-line to stderr." From 56308331e83cf8d4c0042ebc17ce09610e3f9756 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 10:09:23 -0400 Subject: [PATCH 09/25] Add host and port top-level options --- .../com/yetanalytics/persephone/server.clj | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 0f3cbb8..4121fb1 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -93,13 +93,13 @@ :post [request-body main-intercept] :route-name :server/statements]})) -(defn- start-server [mode-k] +(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 "localhost" - ::http/port 8080 + ::http/host host + ::http/port port ::http/join? false}] (http/start (http/create-server server-map)))) @@ -108,26 +108,39 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def top-level-options - [["-h" "--help" "Display the top-level help guide."]]) + [[nil "--host HOST" "The hostname of the webserver endpoint." + :id :host + :default "localhost"] + [nil "--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."]]) -(def top-level-summary - (str "Usage 'persephone-server ' or 'persephone-server [-h|--help]'\n" +(defn- top-level-summary [option-specs] + (str "Usage 'persephone-server [--host STRING] [--port INTEGER] [--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 main command has the following optional arguments, along with defaults:\n" + (cli/summarize option-specs) "\n" + "\n" "Run 'persephone-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 (fn [_] top-level-summary)) + :summary-fn top-level-summary) + {:keys [host port help]} + options [subcommand & rest] arguments] (cond - (:help options) + help (do (println summary) (System/exit 0)) (= "validate" subcommand) @@ -138,7 +151,7 @@ (= :error k) (System/exit 1) :else - (start-server :validate))) + (start-server :validate host port))) (= "match" subcommand) (let [k (m/compile-patterns! rest)] (cond @@ -147,7 +160,7 @@ (= :error k) (System/exit 1) :else - (start-server :match))) + (start-server :match host port))) :else (do (u/printerr [(format "Unknown subcommand: %s" subcommand)]) (System/exit 1))))) From e9a420b533f026393697a67608a0444b38e83494 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 10:14:05 -0400 Subject: [PATCH 10/25] Minor tweaks to top-level summary --- src/server/com/yetanalytics/persephone/server.clj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 4121fb1..54ad459 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -108,18 +108,18 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def top-level-options - [[nil "--host HOST" "The hostname of the webserver endpoint." + [[nil "--host HOST" "The hostname of the webserver endpoint" :id :host :default "localhost"] - [nil "--port PORT" "The port number of the webserver endpoint. Must be between 0 and 65536." + [nil "--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."]]) + ["-h" "--help" "Display the top-level help guide"]]) (defn- top-level-summary [option-specs] - (str "Usage 'persephone-server [--host STRING] [--port INTEGER] [--help|-h] '\n" + (str "Usage 'persephone-server [--host HOST] [--port 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" @@ -128,7 +128,7 @@ "The main command has the following optional arguments, along with defaults:\n" (cli/summarize option-specs) "\n" "\n" - "Run 'persephone-server --help' for details on each subcommand")) + "Run 'persephone-server --help' for details on each subcommand.")) (defn -main [& args] (let [{:keys [options summary arguments]} From d19df6387b21d9006c74a8d883b5590c771744e8 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 11:37:59 -0400 Subject: [PATCH 11/25] Add webserver tests --- deps.edn | 12 +- .../com/yetanalytics/persephone/server.clj | 13 +- .../yetanalytics/persephone/server/util.clj | 2 +- .../server_test/match_test.clj | 93 ++++++++++++++ .../server_test/validate_test.clj | 118 ++++++++++++++++++ 5 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 src/test/com/yetanalytics/persephone_test/server_test/match_test.clj create mode 100644 src/test/com/yetanalytics/persephone_test/server_test/validate_test.clj diff --git a/deps.edn b/deps.edn index 299f2b0..4959148 100644 --- a/deps.edn +++ b/deps.edn @@ -25,8 +25,8 @@ cheshire/cheshire {:mvn/version "5.11.0"}}} :server {:extra-paths ["src/server"] - :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} - org.clojure/tools.cli {:mvn/version "1.0.206"} + :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} + org.clojure/tools.cli {:mvn/version "1.0.206"} io.pedestal/pedestal.service {:mvn/version "0.5.10"} io.pedestal/pedestal.jetty {:mvn/version "0.5.10"} org.slf4j/slf4j-simple {:mvn/version "1.7.28"}}} @@ -40,10 +40,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" "src/gen" "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/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 54ad459..16be644 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -30,7 +30,9 @@ :enter (fn validate [context] (let [statements (get-in context [:request :json-params]) - statement (if (vector? statements) (last statements) statements) + 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?) @@ -53,7 +55,9 @@ :enter (fn match [context] (let [statements (get-in context [:request :json-params]) - statements (if (vector? statements) statements [statements]) + statements (if (sequential? statements) + statements + [statements]) stmts-err (u/statements-err-data statements) stmts-err? (some? stmts-err) state-map (and (not stmts-err?) @@ -93,7 +97,7 @@ :post [request-body main-intercept] :route-name :server/statements]})) -(defn- start-server [mode-k host port] +(defn start-server [mode-k host port] (let [routes-set (routes mode-k) server-map {::http/routes routes-set ::http/type :jetty @@ -103,6 +107,9 @@ ::http/join? false}] (http/start (http/create-server server-map)))) +(defn stop-server [server] + (http/stop server)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Server Init CLI ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/src/server/com/yetanalytics/persephone/server/util.clj b/src/server/com/yetanalytics/persephone/server/util.clj index 8dc8514..a7160e6 100644 --- a/src/server/com/yetanalytics/persephone/server/util.clj +++ b/src/server/com/yetanalytics/persephone/server/util.clj @@ -24,7 +24,7 @@ (defn statement-err-data [s] (s/explain-data ::xs/statement s)) -(defn statements-err-data [s] (s/explain-data (s/coll-of ::xs/statement) s)) +(defn statements-err-data [s] (s/explain-data ::xs/statements s)) (defn printerr "Print the `err-messages` vector line-by-line to stderr." 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..ad5f966 --- /dev/null +++ b/src/test/com/yetanalytics/persephone_test/server_test/match_test.clj @@ -0,0 +1,93 @@ +(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.persephone.server :as server] + [com.yetanalytics.persephone.server.match :as m])) + +(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-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)))))) + (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)))) + (test-match-server + "Pattern matching works (vacuously) w/ zero patterns" + (list "-p" profile-uri "-i" "http://fake-pattern.com") + (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..3d3dd75 --- /dev/null +++ b/src/test/com/yetanalytics/persephone_test/server_test/validate_test.clj @@ -0,0 +1,118 @@ +(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.persephone.server :as server] + [com.yetanalytics.persephone.server.validate :as v])) + +(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-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)))))) + (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))))) + (test-validate-server + "Validation works (vacuously) w/ zero templates" + (list "-p" profile-uri "-i" "http://fake-template.com") + (let [{:keys [status]} + (curl/post "localhost:8080/statements" + (post-map statement))] + (is (= 204 status))))) From 175261ff4ac4e872517180a20b9d0d1cf3c14cd2 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 16:01:16 -0400 Subject: [PATCH 12/25] Add error catching for invalid JSON --- .../com/yetanalytics/persephone/server.clj | 20 ++++++++++++------- .../server_test/match_test.clj | 16 +++++++++++++++ .../server_test/validate_test.clj | 16 +++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 16be644..6ace7c6 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -12,18 +12,23 @@ ;; Interceptors ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def request-body - "Interceptor that performs parsing for request bodies `application/json` - content type, keeping keys as strings instead of keywordizing them." - (body-params/body-params - {#"^application/json" (body-params/custom-json-parser :key-fn str)})) - (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 @@ -94,7 +99,8 @@ :get [health] :route-name :server/health] ["/statements" - :post [request-body main-intercept] + :post [request-body + main-intercept] :route-name :server/statements]})) (defn start-server [mode-k host port] 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 index ad5f966..da5bee4 100644 --- a/src/test/com/yetanalytics/persephone_test/server_test/match_test.clj +++ b/src/test/com/yetanalytics/persephone_test/server_test/match_test.clj @@ -75,6 +75,22 @@ (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 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 index 3d3dd75..9b97082 100644 --- a/src/test/com/yetanalytics/persephone_test/server_test/validate_test.clj +++ b/src/test/com/yetanalytics/persephone_test/server_test/validate_test.clj @@ -65,6 +65,22 @@ (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 From f1b64a089a8176c37fee599303937aae5555b097 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 16:02:17 -0400 Subject: [PATCH 13/25] Rename persephone-server.sh to server.sh --- bin/{persephone-server.sh => server.sh} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename bin/{persephone-server.sh => server.sh} (100%) diff --git a/bin/persephone-server.sh b/bin/server.sh similarity index 100% rename from bin/persephone-server.sh rename to bin/server.sh From f56e665aad4a613de0e29839e7dbef9f621f927a Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 16:07:00 -0400 Subject: [PATCH 14/25] Remove --compile-nfa flag from server --- src/server/com/yetanalytics/persephone/server/match.clj | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/server/com/yetanalytics/persephone/server/match.clj b/src/server/com/yetanalytics/persephone/server/match.clj index 02d1c8f..66c65f2 100644 --- a/src/server/com/yetanalytics/persephone/server/match.clj +++ b/src/server/com/yetanalytics/persephone/server/match.clj @@ -2,6 +2,9 @@ (:require [com.yetanalytics.persephone :as per] [com.yetanalytics.persephone.server.util :as u])) +;; 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." @@ -18,11 +21,6 @@ :multi true :validate [u/iri? u/iri-err-msg] :update-fn (fnil conj [])] - ["-n" "--compile-nfa" - (str "If set, compiles the Patterns into a non-deterministic finite " - "automaton (NFA) instead of a deterministic one, allowing for " - "more detailed error traces at the cost of decreased performance.") - :id :compile-nfa] ["-h" "--help" "Display the 'match' subcommand help menu."]]) (defonce match-ref From 2173bc4b0378efeda8436725b5a3513c2a796640 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 16:49:35 -0400 Subject: [PATCH 15/25] Add short flags for host and port + edit summary --- src/server/com/yetanalytics/persephone/server.clj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 6ace7c6..2ca8c56 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -121,10 +121,10 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def top-level-options - [[nil "--host HOST" "The hostname of the webserver endpoint" + [["-H" "--host HOST" "The hostname of the webserver endpoint" :id :host :default "localhost"] - [nil "--port PORT" "The port number of the webserver endpoint; must be between 0 and 65536" + ["-P" "--port PORT" "The port number of the webserver endpoint; must be between 0 and 65536" :id :port :default 8080 :parse-fn #(Integer/parseInt %) @@ -132,16 +132,16 @@ ["-h" "--help" "Display the top-level help guide"]]) (defn- top-level-summary [option-specs] - (str "Usage 'persephone-server [--host HOST] [--port PORT] [--help|-h] '\n" + (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 main command has the following optional arguments, along with defaults:\n" + "The 'server' command has the following optional arguments, along with defaults:\n" (cli/summarize option-specs) "\n" "\n" - "Run 'persephone-server --help' for details on each subcommand.")) + "Run 'server --help' for details on each subcommand.")) (defn -main [& args] (let [{:keys [options summary arguments]} From 72793d0d5845195609beacf011a9d41b93efd2ca Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 16:49:40 -0400 Subject: [PATCH 16/25] Add webserver docs --- README.md | 1 + doc/server.md | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 doc/server.md 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/doc/server.md b/doc/server.md new file mode 100644 index 0000000..ef8a4e4 --- /dev/null +++ b/doc/server.md @@ -0,0 +1,124 @@ +# 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 http://0.0.0.0:8080/health +``` +which will return an response with status `200` and 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: +``` +% curl localhost:8080/statements \ + -H "Content-Type: application/json" \ + -d @sample_statements/calibration_1.json +``` +This will return a `204 No Content` response, indicating validation success. 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: +``` +% curl localhost:8080/statements \ + -H "Content-Type: application/json" \ + -d @sample_statements/adl_1.json +``` +then we receive a `400 Bad Request` response 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`. + +Confusingly, we will also receive a `400 Bad Request` error if we input a completely invalid statement: +``` +% curl 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). + +# 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: +``` +% curl 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. 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 request 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 request bdoy 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 +``` From 629dc0092fdd32369d6ba365a642a94531a75395 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Thu, 27 Apr 2023 16:53:02 -0400 Subject: [PATCH 17/25] Add to Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6130185..a3755b8 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` From ea8a030e1482e406b3a3230f32d6d9760de5f922 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Fri, 28 Apr 2023 16:37:19 -0400 Subject: [PATCH 18/25] Apply same compile-time validation to server init --- doc/server.md | 4 +- .../com/yetanalytics/persephone/server.clj | 2 +- .../yetanalytics/persephone/server/match.clj | 28 ++++++----- .../yetanalytics/persephone/server/util.clj | 46 ++++++++++++----- .../persephone/server/validate.clj | 33 +++++++------ .../persephone_test/cli_test/match_test.clj | 2 +- .../server_test/match_test.clj | 45 +++++++++++++---- .../server_test/validate_test.clj | 49 ++++++++++++++----- 8 files changed, 144 insertions(+), 65 deletions(-) diff --git a/doc/server.md b/doc/server.md index ef8a4e4..62ef7e4 100644 --- a/doc/server.md +++ b/doc/server.md @@ -81,7 +81,7 @@ Validation also works with two Profiles: --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). +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 @@ -116,9 +116,9 @@ 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/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 2ca8c56..db6e62d 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -175,5 +175,5 @@ :else (start-server :match host port))) :else - (do (u/printerr [(format "Unknown subcommand: %s" subcommand)]) + (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 index 66c65f2..c96a3ae 100644 --- a/src/server/com/yetanalytics/persephone/server/match.clj +++ b/src/server/com/yetanalytics/persephone/server/match.clj @@ -1,6 +1,7 @@ (ns com.yetanalytics.persephone.server.match (:require [com.yetanalytics.persephone :as per] - [com.yetanalytics.persephone.server.util :as u])) + [com.yetanalytics.persephone.server.util :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. @@ -27,21 +28,24 @@ (atom {:matchers nil})) (defn compile-patterns! - "Parse `arglist`, compile Profiles into FSMs, and store them in-memory." + "Parse `arglist`, compile Profiles into FSMs, and store them in-memory. + Return either `:help`, `:error`, or `nil`." [arglist] (let [options (u/handle-args arglist match-statements-options)] (if (keyword? options) options - (let [{:keys [profiles pattern-ids compile-nfa]} - options - matchers - (per/compile-profiles->fsms - profiles - :validate-profiles? false - :compile-nfa? compile-nfa - :selected-patterns (not-empty pattern-ids))] - (swap! match-ref assoc :matchers matchers) - nil)))) + (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/handle-asserts e) + :error))))) (defn match "Perform validation on `statements` against the FSM matchers that diff --git a/src/server/com/yetanalytics/persephone/server/util.clj b/src/server/com/yetanalytics/persephone/server/util.clj index a7160e6..5c688e1 100644 --- a/src/server/com/yetanalytics/persephone/server/util.clj +++ b/src/server/com/yetanalytics/persephone/server/util.clj @@ -2,37 +2,59 @@ (:require [clojure.spec.alpha :as s] [clojure.tools.cli :as cli] [xapi-schema.spec :as xs] - [com.yetanalytics.pan :as pan] [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])) (defn read-profile [profile-filename] (json/coerce-profile (slurp profile-filename))) -(defn read-statement [statement-filename] - (json/coerce-statement (slurp statement-filename))) - -(defn not-empty? [coll] (boolean (not-empty coll))) - (defn iri? [x] (s/valid? ::ax/iri x)) (def iri-err-msg "Must be a valid IRI.") -(defn profile? [p] (nil? (pan/validate-profile p))) +;; Basic validation; most validation will be done at the API level +(defn profile? [p] (map? p)) -(defn profile-err-msg [p] (pan/validate-profile p :result :string)) +(defn profile-err-msg [_] "Must be a valid JSON object.") (defn statement-err-data [s] (s/explain-data ::xs/statement s)) (defn statements-err-data [s] (s/explain-data ::xs/statements s)) (defn printerr - "Print the `err-messages` vector line-by-line to stderr." - [err-messages] + "Print the `error-messages` vector line-by-line to stderr." + [& error-messages] (binding [*out* *err*] - (run! println err-messages)) + (run! println error-messages)) (flush)) +(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))) + (defn handle-args "Parse `args` based on `cli-options` (which should follow the tools.cli specification) and either return `:error`, print `--help` command and @@ -49,7 +71,7 @@ :help) ;; Display error message and exit (not-empty errors) - (do (printerr errors) + (do (apply printerr errors) :error) ;; Do the things :else diff --git a/src/server/com/yetanalytics/persephone/server/validate.clj b/src/server/com/yetanalytics/persephone/server/validate.clj index fb23e08..610ddbc 100644 --- a/src/server/com/yetanalytics/persephone/server/validate.clj +++ b/src/server/com/yetanalytics/persephone/server/validate.clj @@ -1,6 +1,7 @@ (ns com.yetanalytics.persephone.server.validate (:require [com.yetanalytics.persephone :as per] - [com.yetanalytics.persephone.server.util :as u])) + [com.yetanalytics.persephone.server.util :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 @@ -41,24 +42,26 @@ (defn compile-templates! "Parse `arglist`, compile Profiles into Template validators, and store them - in-memory." + in-memory. Returns either `:help`, `:error`, or `nil`." [arglist] (let [options (u/handle-args arglist validate-statement-options)] (if (keyword? options) options - (let [{:keys [profiles template-ids all-valid short-circuit]} - options - validators - (per/compile-profiles->validators - profiles - :validate-profiles? false - :selected-templates (not-empty template-ids))] - (swap! validator-ref - assoc - :validators validators - :all-valid? all-valid - :short-circuit? short-circuit) - nil)))) + (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/handle-asserts e) + :error))))) (defn validate "Perform validation on `statement` against the Template validators that 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 index da5bee4..acc8b28 100644 --- a/src/test/com/yetanalytics/persephone_test/server_test/match_test.clj +++ b/src/test/com/yetanalytics/persephone_test/server_test/match_test.clj @@ -1,9 +1,11 @@ (ns com.yetanalytics.persephone-test.server-test.match-test - (:require [clojure.test :refer [deftest testing is]] - [clojure.edn :as edn] + (: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])) + [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") @@ -34,6 +36,36 @@ :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:" @@ -96,13 +128,6 @@ (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)))) - (test-match-server - "Pattern matching works (vacuously) w/ zero patterns" - (list "-p" profile-uri "-i" "http://fake-pattern.com") (let [{:keys [status]} (curl/post "localhost:8080/statements" (post-map (str "[" statement-1 "," statement-2 "]")))] 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 index 9b97082..013d988 100644 --- a/src/test/com/yetanalytics/persephone_test/server_test/validate_test.clj +++ b/src/test/com/yetanalytics/persephone_test/server_test/validate_test.clj @@ -1,9 +1,11 @@ (ns com.yetanalytics.persephone-test.server-test.validate-test - (:require [clojure.test :refer [deftest testing is]] - [clojure.edn :as edn] - [babashka.curl :as curl] + (: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.server.validate :as v] + [com.yetanalytics.persephone-test.test-utils :refer [with-err-str]])) (def profile-uri "test-resources/sample_profiles/calibration.jsonld") @@ -33,6 +35,36 @@ :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:" @@ -124,11 +156,4 @@ (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 works (vacuously) w/ zero templates" - (list "-p" profile-uri "-i" "http://fake-template.com") - (let [{:keys [status]} - (curl/post "localhost:8080/statements" - (post-map statement))] - (is (= 204 status))))) + (-> body edn/read-string :contents keys set)))))) From d427b97e5b24d61956f9d3281af99460bca40175 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Fri, 28 Apr 2023 16:55:03 -0400 Subject: [PATCH 19/25] Extract out CLI utils into an src/main ns --- src/cli/com/yetanalytics/persephone/cli.clj | 5 +- .../com/yetanalytics/persephone/cli/match.clj | 22 +++---- .../yetanalytics/persephone/cli/util/args.clj | 31 --------- .../yetanalytics/persephone/cli/util/file.clj | 10 --- .../yetanalytics/persephone/cli/util/spec.clj | 54 ---------------- .../yetanalytics/persephone/cli/validate.clj | 26 ++++---- .../yetanalytics/persephone/utils/cli.clj} | 64 ++++++++++++++----- .../com/yetanalytics/persephone/server.clj | 3 +- .../yetanalytics/persephone/server/match.clj | 10 +-- .../persephone/server/validate.clj | 10 +-- 10 files changed, 88 insertions(+), 147 deletions(-) delete mode 100644 src/cli/com/yetanalytics/persephone/cli/util/args.clj delete mode 100644 src/cli/com/yetanalytics/persephone/cli/util/file.clj delete mode 100644 src/cli/com/yetanalytics/persephone/cli/util/spec.clj rename src/{server/com/yetanalytics/persephone/server/util.clj => main/com/yetanalytics/persephone/utils/cli.clj} (51%) diff --git a/src/cli/com/yetanalytics/persephone/cli.clj b/src/cli/com/yetanalytics/persephone/cli.clj index 4713f6e..40a1e3c 100644 --- a/src/cli/com/yetanalytics/persephone/cli.clj +++ b/src/cli/com/yetanalytics/persephone/cli.clj @@ -1,8 +1,9 @@ (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.util.args :refer [printerr]]) (:gen-class)) (def top-level-options @@ -39,5 +40,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..f9b712c 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,22 +10,22 @@ :id :profiles :missing "No Profiles specified." :multi true - :parse-fn f/read-profile - :validate [s/profile? s/profile-err-msg] + :parse-fn u/read-profile + :validate [u/profile? u/profile-err-msg] :update-fn (fnil conj [])] ["-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] + :validate [u/iri? u/iri-err-msg] :update-fn (fnil conj [])] ["-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] + :parse-fn u/read-statement + :validate [u/statements? u/statements-err-msg] :update-fn (fn [xs s] (let [xs (or xs [])] (if (vector? s) (into xs s) (conj xs s))))] @@ -54,7 +53,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 +61,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..c3bdfce 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] + :parse-fn u/read-profile + :validate [u/profile? u/profile-err-msg] :update-fn (fnil conj [])] ["-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] + :validate [u/iri? u/iri-err-msg] :update-fn (fnil conj [])] ["-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,8 +35,8 @@ "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] + :parse-fn u/read-statement + :validate [u/statements? u/statements-err-msg] :update-fn (fn [xs s] (let [xs (or xs [])] (if (vector? s) (into xs s) (conj xs s))))] @@ -85,7 +84,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 +92,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/server/com/yetanalytics/persephone/server/util.clj b/src/main/com/yetanalytics/persephone/utils/cli.clj similarity index 51% rename from src/server/com/yetanalytics/persephone/server/util.clj rename to src/main/com/yetanalytics/persephone/utils/cli.clj index 5c688e1..20037fa 100644 --- a/src/server/com/yetanalytics/persephone/server/util.clj +++ b/src/main/com/yetanalytics/persephone/utils/cli.clj @@ -1,28 +1,59 @@ -(ns com.yetanalytics.persephone.server.util +(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] - [clojure.tools.cli :as cli] [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])) + [com.yetanalytics.persephone.utils.json :as json])) -(defn read-profile [profile-filename] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; File reading +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn read-profile + [profile-filename] (json/coerce-profile (slurp profile-filename))) +(defn read-statement + [statement-filename] + (json/coerce-statement (slurp statement-filename))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Validation +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (defn iri? [x] (s/valid? ::ax/iri x)) -(def iri-err-msg "Must be a valid IRI.") +(defn iri-err-msg [_] "Must be a valid IRI.") -;; Basic validation; most validation will be done at the API level +;; 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] @@ -30,9 +61,10 @@ (run! println error-messages)) (flush)) -(defn handle-asserts +(defn print-assert-errors "Handle all possible assertions given an ExceptionInfo `ex` thrown from - the `persephone.utils.asserts` namespace." + 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)) @@ -55,15 +87,13 @@ ;; else (throw ex))) -(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] +(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 diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index db6e62d..9405563 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -3,9 +3,10 @@ [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] - [com.yetanalytics.persephone.server.util :as u]) + #_[com.yetanalytics.persephone.server.util :as u]) (:gen-class)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/src/server/com/yetanalytics/persephone/server/match.clj b/src/server/com/yetanalytics/persephone/server/match.clj index c96a3ae..235cbaa 100644 --- a/src/server/com/yetanalytics/persephone/server/match.clj +++ b/src/server/com/yetanalytics/persephone/server/match.clj @@ -1,6 +1,7 @@ (ns com.yetanalytics.persephone.server.match - (:require [com.yetanalytics.persephone :as per] - [com.yetanalytics.persephone.server.util :as u]) + (: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 @@ -31,7 +32,8 @@ "Parse `arglist`, compile Profiles into FSMs, and store them in-memory. Return either `:help`, `:error`, or `nil`." [arglist] - (let [options (u/handle-args arglist match-statements-options)] + (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]} @@ -44,7 +46,7 @@ (swap! match-ref assoc :matchers matchers) true) (catch ExceptionInfo e - (u/handle-asserts e) + (u/print-assert-errors e) :error))))) (defn match diff --git a/src/server/com/yetanalytics/persephone/server/validate.clj b/src/server/com/yetanalytics/persephone/server/validate.clj index 610ddbc..9fabc84 100644 --- a/src/server/com/yetanalytics/persephone/server/validate.clj +++ b/src/server/com/yetanalytics/persephone/server/validate.clj @@ -1,6 +1,7 @@ (ns com.yetanalytics.persephone.server.validate - (:require [com.yetanalytics.persephone :as per] - [com.yetanalytics.persephone.server.util :as u]) + (: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 @@ -44,7 +45,8 @@ "Parse `arglist`, compile Profiles into Template validators, and store them in-memory. Returns either `:help`, `:error`, or `nil`." [arglist] - (let [options (u/handle-args arglist validate-statement-options)] + (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]} @@ -60,7 +62,7 @@ :short-circuit? short-circuit) nil) (catch ExceptionInfo e - (u/handle-asserts e) + (u/print-assert-errors e) :error))))) (defn validate From 00e779485787ee472174d60efcd55f2c9285b559 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Fri, 28 Apr 2023 17:04:15 -0400 Subject: [PATCH 20/25] Add argv conj util fns --- src/cli/com/yetanalytics/persephone/cli/match.clj | 8 +++----- .../com/yetanalytics/persephone/cli/validate.clj | 8 +++----- src/main/com/yetanalytics/persephone/utils/cli.clj | 14 ++++++++++++++ .../com/yetanalytics/persephone/server/match.clj | 4 ++-- .../yetanalytics/persephone/server/validate.clj | 4 ++-- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/cli/com/yetanalytics/persephone/cli/match.clj b/src/cli/com/yetanalytics/persephone/cli/match.clj index f9b712c..8f7537f 100644 --- a/src/cli/com/yetanalytics/persephone/cli/match.clj +++ b/src/cli/com/yetanalytics/persephone/cli/match.clj @@ -12,13 +12,13 @@ :multi true :parse-fn u/read-profile :validate [u/profile? u/profile-err-msg] - :update-fn (fnil conj [])] + :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 [u/iri? u/iri-err-msg] - :update-fn (fnil conj [])] + :update-fn u/conj-argv] ["-s" "--statement URI" "Statement filepath/location; must specify one or more. Accepts arrays of Statements." :id :statements @@ -26,9 +26,7 @@ :multi true :parse-fn u/read-statement :validate [u/statements? u/statements-err-msg] - :update-fn (fn [xs s] - (let [xs (or xs [])] - (if (vector? s) (into xs s) (conj xs s))))] + :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 " diff --git a/src/cli/com/yetanalytics/persephone/cli/validate.clj b/src/cli/com/yetanalytics/persephone/cli/validate.clj index c3bdfce..1e8812a 100644 --- a/src/cli/com/yetanalytics/persephone/cli/validate.clj +++ b/src/cli/com/yetanalytics/persephone/cli/validate.clj @@ -14,13 +14,13 @@ :multi true :parse-fn u/read-profile :validate [u/profile? u/profile-err-msg] - :update-fn (fnil conj [])] + :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 [u/iri? u/iri-err-msg] - :update-fn (fnil conj [])] + :update-fn u/conj-argv] ["-s" "--statement URI" "Statement filepath/location; must specify one." :id :statement @@ -37,9 +37,7 @@ :multi true :parse-fn u/read-statement :validate [u/statements? u/statements-err-msg] - :update-fn (fn [xs s] - (let [xs (or xs [])] - (if (vector? s) (into xs s) (conj xs s))))] + :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. " diff --git a/src/main/com/yetanalytics/persephone/utils/cli.clj b/src/main/com/yetanalytics/persephone/utils/cli.clj index 20037fa..1ea2bd1 100644 --- a/src/main/com/yetanalytics/persephone/utils/cli.clj +++ b/src/main/com/yetanalytics/persephone/utils/cli.clj @@ -20,6 +20,20 @@ [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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/src/server/com/yetanalytics/persephone/server/match.clj b/src/server/com/yetanalytics/persephone/server/match.clj index 235cbaa..1958114 100644 --- a/src/server/com/yetanalytics/persephone/server/match.clj +++ b/src/server/com/yetanalytics/persephone/server/match.clj @@ -15,14 +15,14 @@ :multi true :parse-fn u/read-profile :validate [u/profile? u/profile-err-msg] - :update-fn (fnil conj [])] + :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 (fnil conj [])] + :update-fn u/conj-argv] ["-h" "--help" "Display the 'match' subcommand help menu."]]) (defonce match-ref diff --git a/src/server/com/yetanalytics/persephone/server/validate.clj b/src/server/com/yetanalytics/persephone/server/validate.clj index 9fabc84..590d573 100644 --- a/src/server/com/yetanalytics/persephone/server/validate.clj +++ b/src/server/com/yetanalytics/persephone/server/validate.clj @@ -16,14 +16,14 @@ :multi true :parse-fn u/read-profile :validate [u/profile? u/profile-err-msg] - :update-fn (fnil conj [])] + :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 (fnil conj [])] + :update-fn u/conj-argv] ["-a" "--all-valid" (str "If set, any Statement is not considered valid unless it is valid " "against ALL Templates. " From 1b8690d0681b6ac715d77c860d9026bcb9f47983 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Mon, 1 May 2023 13:14:11 -0400 Subject: [PATCH 21/25] Update pedestal deps to avoid CVEs --- deps.edn | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/deps.edn b/deps.edn index 512ef74..a2a7b6c 100644 --- a/deps.edn +++ b/deps.edn @@ -21,15 +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"} - io.pedestal/pedestal.service {:mvn/version "0.5.10"} - io.pedestal/pedestal.jetty {:mvn/version "0.5.10"} - org.slf4j/slf4j-simple {:mvn/version "1.7.28"}}} + :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"} From 4eaf23590c61e4b61e38d19dcfb8421803a3337b Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Mon, 1 May 2023 13:15:10 -0400 Subject: [PATCH 22/25] Remove non-existent src/gen dir from test paths --- deps.edn | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps.edn b/deps.edn index a2a7b6c..9d9d1f3 100644 --- a/deps.edn +++ b/deps.edn @@ -62,7 +62,7 @@ criterium/criterium {:mvn/version "0.4.6"} ; clj only com.taoensso/tufte {:mvn/version "2.2.0"}}} :test - {:extra-paths ["src/cli" "src/server" "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]} From 1757eef05a7d0786fdf97168fbfa5018f509a064 Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Mon, 1 May 2023 13:26:54 -0400 Subject: [PATCH 23/25] Add response headers to docs --- deps.edn | 2 +- doc/server.md | 104 +++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/deps.edn b/deps.edn index 9d9d1f3..5613a78 100644 --- a/deps.edn +++ b/deps.edn @@ -21,7 +21,7 @@ {:cli {:extra-paths ["src/cli"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} - org.clojure/tools.cli {:mvn/version "1.0.206"} }} + org.clojure/tools.cli {:mvn/version "1.0.206"}}} :server {:extra-paths ["src/server"] :extra-deps {org.clojure/clojure {:mvn/version "1.10.2"} diff --git a/doc/server.md b/doc/server.md index 62ef7e4..5a6983e 100644 --- a/doc/server.md +++ b/doc/server.md @@ -30,9 +30,23 @@ The `match` subcommand starts the server in match mode, where any Statements sen In addition to the `POST /statements` endpoint, there is a `GET /health` endpoint that is used to perform a server health check: ``` -% curl http://0.0.0.0:8080/health -``` -which will return an response with status `200` and body `OK`. +% 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. @@ -45,29 +59,57 @@ For the first few examples, let us start a webserver in validate mode with a sin 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: -``` -% curl localhost:8080/statements \ +```bash +% curl -i localhost:8080/statements \ -H "Content-Type: application/json" \ -d @sample_statements/calibration_1.json ``` -This will return a `204 No Content` response, indicating validation success. 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. +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 localhost:8080/statements \ -H "Content-Type: application/json" \ -d @sample_statements/adl_1.json ``` -then we receive a `400 Bad Request` response and an EDN response body that looks like the following: +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`. -Confusingly, we will also receive a `400 Bad Request` error if we input a completely invalid statement: -``` -% curl localhost:8080/statements \ +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"}' ``` @@ -92,21 +134,49 @@ In match mode, we will first start a webserver mode with a single Profile. Runni 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: -``` -% curl localhost:8080/statements \ +```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. 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 request body: +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 request bdoy will be of the form +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 {...}}} From 2b0345aed465957270e6d7ae5e139a9ecd7dc1fd Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Mon, 1 May 2023 13:27:30 -0400 Subject: [PATCH 24/25] Remove commented-out requires --- src/cli/com/yetanalytics/persephone/cli.clj | 3 +-- src/server/com/yetanalytics/persephone/server.clj | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/cli/com/yetanalytics/persephone/cli.clj b/src/cli/com/yetanalytics/persephone/cli.clj index 40a1e3c..56e8c20 100644 --- a/src/cli/com/yetanalytics/persephone/cli.clj +++ b/src/cli/com/yetanalytics/persephone/cli.clj @@ -2,8 +2,7 @@ (: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 diff --git a/src/server/com/yetanalytics/persephone/server.clj b/src/server/com/yetanalytics/persephone/server.clj index 9405563..0884feb 100644 --- a/src/server/com/yetanalytics/persephone/server.clj +++ b/src/server/com/yetanalytics/persephone/server.clj @@ -5,8 +5,7 @@ [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] - #_[com.yetanalytics.persephone.server.util :as u]) + [com.yetanalytics.persephone.server.match :as m]) (:gen-class)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; From 8073bf91c7cd13534b8e931781efedfc57601a1a Mon Sep 17 00:00:00 2001 From: kelvinqian00 Date: Mon, 1 May 2023 13:32:13 -0400 Subject: [PATCH 25/25] Forgot an -i flag in the docs --- doc/server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/server.md b/doc/server.md index 5a6983e..1750f77 100644 --- a/doc/server.md +++ b/doc/server.md @@ -81,7 +81,7 @@ We can also input a Statement array, e.g. `sample_statements/calibration_coll.js If we try to validate an invalid Statement: ```bash -% curl localhost:8080/statements \ +% curl -i localhost:8080/statements \ -H "Content-Type: application/json" \ -d @sample_statements/adl_1.json ```